diff --git a/k8-operator/internal/services/infisicalsecret/reconciler.go b/k8-operator/internal/services/infisicalsecret/reconciler.go index c76a78bd2..060677926 100644 --- a/k8-operator/internal/services/infisicalsecret/reconciler.go +++ b/k8-operator/internal/services/infisicalsecret/reconciler.go @@ -601,6 +601,10 @@ func (r *InfisicalSecretReconciler) OpenInstantUpdatesStream(ctx context.Context Event: "secret:delete", Conditions: conditions, }, + { + Event: "secret:import-mutation", + Conditions: conditions, + }, }, }) @@ -612,9 +616,10 @@ func (r *InfisicalSecretReconciler) OpenInstantUpdatesStream(ctx context.Context headers := map[string]string{ "User-Agent": api.USER_AGENT_NAME, "Authorization": fmt.Sprint("Bearer ", token), + "Content-Type": "application/json", } - req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/v1/events/subscribe/project-events", api.API_HOST_URL), strings.NewReader(string(body))) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v1/events/subscribe/project-events", api.API_HOST_URL), strings.NewReader(string(body))) if err != nil { return nil, err diff --git a/k8-operator/internal/util/sse/client.go b/k8-operator/internal/util/sse/client.go index f118611a6..c2bc6900f 100644 --- a/k8-operator/internal/util/sse/client.go +++ b/k8-operator/internal/util/sse/client.go @@ -2,48 +2,61 @@ package sse import ( "bufio" + "context" "fmt" "io" "net/http" "strings" - "sync" "time" ) -type SSEEvent struct { +// Event represents a Server-Sent Event +type Event struct { ID string Event string Data string + Retry int } -// SSEClient handles SSE connections -type SSEClient struct { - URL string - Client *http.Client - LastHealthCheck time.Time - mu *sync.Mutex // for safe concurrent access to LastHealthCheck +// Client handles SSE connections with high performance +type Client struct { + httpClient *http.Client + onPing func() // Callback for ping events } -// NewClient creates a new SSE client -func NewClient() SSEClient { - return SSEClient{ - mu: &sync.Mutex{}, - Client: &http.Client{ +// NewClient creates a new high-performance SSE client +func NewClient() Client { + return Client{ + httpClient: &http.Client{ Timeout: 0, // No timeout for streaming + Transport: &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: true, // SSE typically doesn't benefit from compression + }, }, } } -// Connect establishes SSE connection and returns a channel of events -func (c *SSEClient) Connect(req *http.Request) (<-chan SSEEvent, <-chan error, error) { - // Set required headers for SSE - req.Header.Set("Cache-Control", "no-cache") - req.Header.Set("Connection", "keep-alive") - req.Header.Set("Content-Type", "application/json") +// WithPingHandler sets a callback for ping events +func (c *Client) WithPingHandler(handler func()) *Client { + c.onPing = handler + return c +} - resp, err := c.Client.Do(req) +// Connect establishes an SSE connection and returns event channels +func (c *Client) Connect(ctx context.Context, req *http.Request) (<-chan Event, <-chan error, error) { + // Configure SSE headers + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Connection", "keep-alive") + + // Add context to request + req = req.WithContext(ctx) + + resp, err := c.httpClient.Do(req) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("request failed: %w", err) } if resp.StatusCode != http.StatusOK { @@ -51,66 +64,124 @@ func (c *SSEClient) Connect(req *http.Request) (<-chan SSEEvent, <-chan error, e return nil, nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - eventChan := make(chan SSEEvent) - errorChan := make(chan error) + eventChan := make(chan Event, 10) + errorChan := make(chan error, 1) - go c.stream(resp.Body, eventChan, errorChan) + go c.stream(ctx, resp.Body, eventChan, errorChan) return eventChan, errorChan, nil } -func (c *SSEClient) stream(body io.ReadCloser, eventChan chan<- SSEEvent, errorChan chan<- error) { +func (c *Client) stream(ctx context.Context, body io.ReadCloser, eventChan chan<- Event, errorChan chan<- error) { defer body.Close() defer close(eventChan) defer close(errorChan) scanner := bufio.NewScanner(body) - var event SSEEvent + + var currentEvent Event + var dataBuilder strings.Builder for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + select { + case <-ctx.Done(): + return + default: + } - // End of event - if line == "" { - if event.Data != "" || event.Event != "" { - if strings.TrimSpace(event.Data) == "1" { - c.mu.Lock() - c.LastHealthCheck = time.Now() - c.mu.Unlock() - } else if event.Event != "ping" { - eventChan <- event + line := scanner.Text() + + // Empty line indicates end of event + if len(line) == 0 { + if currentEvent.Data != "" || currentEvent.Event != "" { + // Finalize data + if dataBuilder.Len() > 0 { + currentEvent.Data = dataBuilder.String() + dataBuilder.Reset() } - event = SSEEvent{} // Reset for next event + // Handle ping events + if c.isPingEvent(currentEvent) { + if c.onPing != nil { + c.onPing() + } + } else { + // Send non-ping events + select { + case eventChan <- currentEvent: + case <-ctx.Done(): + return + } + } + + // Reset for next event + currentEvent = Event{} } continue } - switch { - case strings.HasPrefix(line, "data:"): - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if event.Data != "" { - event.Data += "\n" - } - event.Data += data - - case strings.HasPrefix(line, "event:"): - event.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - - case strings.HasPrefix(line, "id:"): - event.ID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) - - case strings.HasPrefix(line, "retry:"): - // Optional: parse and apply retry interval here - - case strings.HasPrefix(line, ":"): - // Comment line — ignored - default: - // Unknown line format — can log/debug if needed - } + // Parse line efficiently + c.parseLine(line, ¤tEvent, &dataBuilder) } if err := scanner.Err(); err != nil { - errorChan <- err + select { + case errorChan <- err: + case <-ctx.Done(): + } } } + +// parseLine efficiently parses SSE protocol lines +func (c *Client) parseLine(line string, event *Event, dataBuilder *strings.Builder) { + colonIndex := strings.IndexByte(line, ':') + if colonIndex == -1 { + return // Invalid line format + } + + field := line[:colonIndex] + value := line[colonIndex+1:] + + // Trim leading space from value (SSE spec) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + + switch field { + case "data": + if dataBuilder.Len() > 0 { + dataBuilder.WriteByte('\n') + } + dataBuilder.WriteString(value) + case "event": + event.Event = value + case "id": + event.ID = value + case "retry": + // Parse retry value if needed + // This could be used to configure reconnection delay + case "": + // Comment line, ignore + } +} + +// isPingEvent checks if an event is a ping/keepalive +func (c *Client) isPingEvent(event Event) bool { + // Check for common ping patterns + if event.Event == "ping" { + return true + } + + // Check for heartbeat data (common pattern is "1" or similar) + if event.Event == "" && strings.TrimSpace(event.Data) == "1" { + return true + } + + return false +} + +// WithHTTPClient sets a custom HTTP client +func (c *Client) WithHTTPClient(client *http.Client) *Client { + c.httpClient = client + return c +} diff --git a/k8-operator/internal/util/sse/sse.go b/k8-operator/internal/util/sse/sse.go index ec4a5dbce..062aafb57 100644 --- a/k8-operator/internal/util/sse/sse.go +++ b/k8-operator/internal/util/sse/sse.go @@ -5,161 +5,203 @@ import ( "fmt" "net/http" "sync" + "sync/atomic" "time" ) +// ConnectionMeta holds metadata about an SSE connection type ConnectionMeta struct { - EventChan <-chan SSEEvent + EventChan <-chan Event ErrorChan <-chan error - LastPingAt time.Time - Cancel context.CancelFunc + lastPingAt atomic.Value // stores time.Time + cancel context.CancelFunc } -type ConnectionRegistry struct { - Ctx context.Context - meta *ConnectionMeta - client SSEClient - mu sync.RWMutex - - monitorCancel context.CancelFunc - monitorCtx context.Context +// LastPing returns the last ping time +func (c *ConnectionMeta) LastPing() time.Time { + if t, ok := c.lastPingAt.Load().(time.Time); ok { + return t + } + return time.Time{} } -func NewConnectionRegistry(ctx context.Context) *ConnectionRegistry { - monitorCtx, monitorCancel := context.WithCancel(ctx) - return &ConnectionRegistry{ - Ctx: ctx, - client: NewClient(), - monitorCtx: monitorCtx, - monitorCancel: monitorCancel, +// UpdateLastPing atomically updates the last ping time +func (c *ConnectionMeta) UpdateLastPing() { + c.lastPingAt.Store(time.Now()) +} + +// Cancel terminates the connection +func (c *ConnectionMeta) Cancel() { + if c.cancel != nil { + c.cancel() } } -// create creates a new connection -func (r *ConnectionRegistry) create(req *http.Request) (*ConnectionMeta, error) { - // Create new connection using provided request - eventChan, errorChan, err := r.client.Connect(req) +// ConnectionRegistry manages SSE connections with high performance +type ConnectionRegistry struct { + ctx context.Context + client Client + + mu sync.RWMutex + conn *ConnectionMeta + + monitorOnce sync.Once + monitorStop chan struct{} +} + +// NewConnectionRegistry creates a new high-performance connection registry +func NewConnectionRegistry(ctx context.Context) *ConnectionRegistry { + r := &ConnectionRegistry{ + ctx: ctx, + monitorStop: make(chan struct{}), + } + + // Configure client with ping handler + r.client = NewClient() + r.client.WithPingHandler(func() { + r.UpdateLastPing() + }) + + return r +} + +// Subscribe provides SSE events, creating a connection if needed +func (r *ConnectionRegistry) Subscribe(buildRequest func() (*http.Request, error)) (<-chan Event, <-chan error, error) { + // Fast path: check if connection exists + if conn := r.getConnection(); conn != nil { + return conn.EventChan, conn.ErrorChan, nil + } + + // Slow path: create new connection under lock + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check after acquiring lock + if r.conn != nil { + return r.conn.EventChan, r.conn.ErrorChan, nil + } + + req, err := buildRequest() if err != nil { + return nil, nil, fmt.Errorf("failed to build request: %w", err) + } + + conn, err := r.createConnection(req) + if err != nil { + return nil, nil, err + } + + r.conn = conn + + // Start monitor once + r.monitorOnce.Do(func() { + go r.monitorConnections() + }) + + return conn.EventChan, conn.ErrorChan, nil +} + +// Get retrieves the current connection +func (r *ConnectionRegistry) Get() (*ConnectionMeta, bool) { + conn := r.getConnection() + return conn, conn != nil +} + +// IsConnected checks if there's an active connection +func (r *ConnectionRegistry) IsConnected() bool { + return r.getConnection() != nil +} + +// UpdateLastPing updates the last ping time for the current connection +func (r *ConnectionRegistry) UpdateLastPing() { + if conn := r.getConnection(); conn != nil { + conn.UpdateLastPing() + } +} + +// Close gracefully shuts down the registry +func (r *ConnectionRegistry) Close() { + // Stop monitor first + select { + case <-r.monitorStop: + // Already closed + default: + close(r.monitorStop) + } + + // Close connection + r.mu.Lock() + if r.conn != nil { + r.conn.Cancel() + r.conn = nil + } + r.mu.Unlock() +} + +// getConnection returns the current connection without locking +func (r *ConnectionRegistry) getConnection() *ConnectionMeta { + r.mu.RLock() + conn := r.conn + r.mu.RUnlock() + return conn +} + +// createConnection creates a new SSE connection +func (r *ConnectionRegistry) createConnection(req *http.Request) (*ConnectionMeta, error) { + ctx, cancel := context.WithCancel(r.ctx) + + eventChan, errorChan, err := r.client.Connect(ctx, req) + if err != nil { + cancel() return nil, fmt.Errorf("failed to connect: %w", err) } meta := &ConnectionMeta{ - EventChan: eventChan, - ErrorChan: errorChan, - LastPingAt: time.Now(), + EventChan: eventChan, + ErrorChan: errorChan, + cancel: cancel, } + meta.UpdateLastPing() - r.meta = meta - - // Start cleanup monitor for this connection (NON-BLOCKING) - go r.monitor(meta) - - println("Creating new connection\n") return meta, nil } -// Get retrieves the existing connection -func (r *ConnectionRegistry) Get() (*ConnectionMeta, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - return r.meta, r.meta != nil -} +// monitorConnections checks connection health periodically +func (r *ConnectionRegistry) monitorConnections() { + const ( + checkInterval = 30 * time.Second + pingTimeout = 2 * time.Minute + ) -// Close closes the connection -func (r *ConnectionRegistry) Close() { - r.mu.Lock() - defer r.mu.Unlock() - - if r.meta != nil { - if r.meta.Cancel != nil { - r.meta.Cancel() - } - r.meta = nil - } - - // Cancel the monitor - if r.monitorCancel != nil { - r.monitorCancel() - } -} - -// IsConnected returns whether there's an active connection -func (r *ConnectionRegistry) IsConnected() bool { - r.mu.RLock() - defer r.mu.RUnlock() - return r.meta != nil -} - -// UpdateLastPing updates the last ping time -func (r *ConnectionRegistry) UpdateLastPing() { - r.mu.Lock() - defer r.mu.Unlock() - - if r.meta != nil { - r.meta.LastPingAt = time.Now() - } -} - -// monitor watches for connection closure and cleans up -func (r *ConnectionRegistry) monitor(meta *ConnectionMeta) { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(checkInterval) defer ticker.Stop() for { select { - case <-r.monitorCtx.Done(): - // Context cancelled, exit monitor + case <-r.monitorStop: + return + case <-r.ctx.Done(): return - case <-ticker.C: - r.mu.RLock() - currentMeta := r.meta - r.mu.RUnlock() - - // Check if this monitor is still relevant - if currentMeta != meta { - // This connection has been replaced, exit monitor - return - } - - if currentMeta != nil && time.Since(currentMeta.LastPingAt) > 2*time.Minute { - fmt.Println("Last ping was more than 2 minutes ago, closing connection") - r.mu.Lock() - if r.meta == meta { // Double-check under lock - if r.meta.Cancel != nil { - r.meta.Cancel() - } - r.meta = nil - } - r.mu.Unlock() - return // Exit monitor after cleanup - } + r.checkConnectionHealth(pingTimeout) } } } -// Subscribe provides a convenient way to get events from the connection -func (r *ConnectionRegistry) Subscribe(build func() (*http.Request, error)) (<-chan SSEEvent, <-chan error, error) { - r.mu.Lock() - defer r.mu.Unlock() - - // Get existing connection if available - if r.meta != nil { - return r.meta.EventChan, r.meta.ErrorChan, nil +// checkConnectionHealth verifies connection is still alive +func (r *ConnectionRegistry) checkConnectionHealth(timeout time.Duration) { + conn := r.getConnection() + if conn == nil { + return } - req, err := build() - - if err != nil { - return nil, nil, err + if time.Since(conn.LastPing()) > timeout { + // Connection is stale, close it + r.mu.Lock() + if r.conn == conn { // Verify it's still the same connection + r.conn.Cancel() + r.conn = nil + } + r.mu.Unlock() } - - // Create new connection if none exists - meta, err := r.create(req) - - if err != nil { - return nil, nil, err - } - - return meta.EventChan, meta.ErrorChan, nil }