feat(proxy): implement cache invalidation for zero-downtime deployment
- Add InvalidateTarget method to proxy service for cache clearing - Add ContainerDeployedHandler to invalidate cache via events - Follow clean architecture: usecases communicate via domain events - Add comprehensive tests for cache invalidation and event handling
- commit
- 92cef49f8f46d2b1acabaf16d33dbc7e0a12a2da
- author
- brice · · 2026-01-12 22:10 UTC
- committer
- brice · · 2026-01-12 17:12 UTC
- parent
- e555572
- signature
- unsigned
3 files · +171 -0
A internal/usecase/proxy/events.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package proxy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | ||
| 8 | + | "gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // TargetInvalidator defines the interface for invalidating proxy targets. | |
| 12 | + | type TargetInvalidator interface { | |
| 13 | + | InvalidateTarget(ctx context.Context, domainName string) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // ContainerDeployedHandler handles container.deployed events to invalidate proxy cache. | |
| 17 | + | type ContainerDeployedHandler struct { | |
| 18 | + | invalidator TargetInvalidator | |
| 19 | + | ctx context.Context | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // NewContainerDeployedHandler creates a new ContainerDeployedHandler. | |
| 23 | + | func NewContainerDeployedHandler(ctx context.Context, invalidator TargetInvalidator) *ContainerDeployedHandler { | |
| 24 | + | return &ContainerDeployedHandler{ | |
| 25 | + | invalidator: invalidator, | |
| 26 | + | ctx: ctx, | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Handle handles a container.deployed event by invalidating the proxy cache. | |
| 31 | + | func (h *ContainerDeployedHandler) Handle(event domain.Event) error { | |
| 32 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |
| 33 | + | zerowrap.FieldLayer: "usecase", | |
| 34 | + | zerowrap.FieldHandler: "ContainerDeployedHandler", | |
| 35 | + | zerowrap.FieldEvent: string(event.Type), | |
| 36 | + | "event_id": event.ID, | |
| 37 | + | }) | |
| 38 | + | log := zerowrap.FromCtx(ctx) | |
| 39 | + | ||
| 40 | + | // Get domain from event | |
| 41 | + | domainName := event.Route | |
| 42 | + | if domainName == "" { | |
| 43 | + | // Try to get from payload | |
| 44 | + | if payload, ok := event.Data.(*domain.ContainerEventPayload); ok { | |
| 45 | + | domainName = payload.Domain | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | if domainName == "" { | |
| 50 | + | log.Debug().Msg("no domain in container deployed event, skipping cache invalidation") | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | log.Debug().Str("domain", domainName).Msg("invalidating proxy cache for deployed container") | |
| 55 | + | h.invalidator.InvalidateTarget(ctx, domainName) | |
| 56 | + | ||
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // CanHandle returns whether this handler can handle the given event type. | |
| 61 | + | func (h *ContainerDeployedHandler) CanHandle(eventType domain.EventType) bool { | |
| 62 | + | return eventType == domain.EventContainerDeployed | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // TargetInvalidator defines the interface for invalidating proxy targets. | |||
| 12 | + | type TargetInvalidator interface { | |||
| 13 | + | InvalidateTarget(ctx context.Context, domainName string) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // ContainerDeployedHandler handles container.deployed events to invalidate proxy cache. | |||
| 17 | + | type ContainerDeployedHandler struct { | |||
| 18 | + | invalidator TargetInvalidator | |||
| 19 | + | ctx context.Context | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // NewContainerDeployedHandler creates a new ContainerDeployedHandler. | |||
| 23 | + | func NewContainerDeployedHandler(ctx context.Context, invalidator TargetInvalidator) *ContainerDeployedHandler { | |||
| 24 | + | return &ContainerDeployedHandler{ | |||
| 25 | + | invalidator: invalidator, | |||
| 26 | + | ctx: ctx, | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Handle handles a container.deployed event by invalidating the proxy cache. | |||
| 31 | + | func (h *ContainerDeployedHandler) Handle(event domain.Event) error { | |||
| 32 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |||
| 33 | + | zerowrap.FieldLayer: "usecase", | |||
| 34 | + | zerowrap.FieldHandler: "ContainerDeployedHandler", | |||
| 35 | + | zerowrap.FieldEvent: string(event.Type), | |||
| 36 | + | "event_id": event.ID, | |||
| 37 | + | }) | |||
| 38 | + | log := zerowrap.FromCtx(ctx) | |||
| 39 | + | ||||
| 40 | + | // Get domain from event | |||
| 41 | + | domainName := event.Route | |||
| 42 | + | if domainName == "" { | |||
| 43 | + | // Try to get from payload | |||
| 44 | + | if payload, ok := event.Data.(*domain.ContainerEventPayload); ok { | |||
| 45 | + | domainName = payload.Domain | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | if domainName == "" { | |||
| 50 | + | log.Debug().Msg("no domain in container deployed event, skipping cache invalidation") | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | log.Debug().Str("domain", domainName).Msg("invalidating proxy cache for deployed container") | |||
| 55 | + | h.invalidator.InvalidateTarget(ctx, domainName) | |||
| 56 | + | ||||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // CanHandle returns whether this handler can handle the given event type. | |||
| 61 | + | func (h *ContainerDeployedHandler) CanHandle(eventType domain.EventType) bool { | |||
| 62 | + | return eventType == domain.EventContainerDeployed | |||
| 63 | + | } | |||
M internal/usecase/proxy/service.go +9 -0
| @@ -179,6 +179,15 @@ func (s *Service) UnregisterTarget(_ context.Context, domainName string) error { | |||
| 179 | 179 | return nil | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request. | |
| 183 | + | // This is used during zero-downtime deployments to switch traffic to a new container. | |
| 184 | + | func (s *Service) InvalidateTarget(_ context.Context, domainName string) { | |
| 185 | + | s.mu.Lock() | |
| 186 | + | defer s.mu.Unlock() | |
| 187 | + | ||
| 188 | + | delete(s.targets, domainName) | |
| 189 | + | } | |
| 190 | + | ||
| 182 | 191 | // RefreshTargets refreshes all proxy targets from container state. | |
| 183 | 192 | func (s *Service) RefreshTargets(ctx context.Context) error { | |
| 184 | 193 | s.mu.Lock() | |
| @@ -179,6 +179,15 @@ func (s *Service) UnregisterTarget(_ context.Context, domainName string) error { | |||||
| 179 | return nil | 179 | return nil | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | + | // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request. | |||
| 183 | + | // This is used during zero-downtime deployments to switch traffic to a new container. | |||
| 184 | + | func (s *Service) InvalidateTarget(_ context.Context, domainName string) { | |||
| 185 | + | s.mu.Lock() | |||
| 186 | + | defer s.mu.Unlock() | |||
| 187 | + | ||||
| 188 | + | delete(s.targets, domainName) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 182 | // RefreshTargets refreshes all proxy targets from container state. | 191 | // RefreshTargets refreshes all proxy targets from container state. | ||
| 183 | func (s *Service) RefreshTargets(ctx context.Context) error { | 192 | func (s *Service) RefreshTargets(ctx context.Context) error { | ||
| 184 | s.mu.Lock() | 193 | s.mu.Lock() | ||
M internal/usecase/proxy/service_test.go +99 -0
| @@ -173,3 +173,102 @@ func TestService_isRunningInContainer(t *testing.T) { | |||
| 173 | 173 | result := svc.isRunningInContainer() | |
| 174 | 174 | assert.IsType(t, true, result) // Just verify it returns a bool | |
| 175 | 175 | } | |
| 176 | + | ||
| 177 | + | func TestService_InvalidateTarget(t *testing.T) { | |
| 178 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 179 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 180 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 181 | + | ||
| 182 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 183 | + | ctx := testContext() | |
| 184 | + | ||
| 185 | + | // Pre-populate cache with target | |
| 186 | + | svc.targets["app.example.com"] = &domain.ProxyTarget{ | |
| 187 | + | Host: "192.168.1.100", | |
| 188 | + | Port: 8080, | |
| 189 | + | ContainerID: "old-container", | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Invalidate the target | |
| 193 | + | svc.InvalidateTarget(ctx, "app.example.com") | |
| 194 | + | ||
| 195 | + | // Verify target is removed from cache | |
| 196 | + | svc.mu.RLock() | |
| 197 | + | _, exists := svc.targets["app.example.com"] | |
| 198 | + | svc.mu.RUnlock() | |
| 199 | + | ||
| 200 | + | assert.False(t, exists, "target should be removed from cache after invalidation") | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestContainerDeployedHandler_CanHandle(t *testing.T) { | |
| 204 | + | handler := NewContainerDeployedHandler(testContext(), nil) | |
| 205 | + | ||
| 206 | + | assert.True(t, handler.CanHandle(domain.EventContainerDeployed)) | |
| 207 | + | assert.False(t, handler.CanHandle(domain.EventImagePushed)) | |
| 208 | + | assert.False(t, handler.CanHandle(domain.EventConfigReload)) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestContainerDeployedHandler_Handle_InvalidatesCache(t *testing.T) { | |
| 212 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 213 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 214 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 215 | + | ||
| 216 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 217 | + | ctx := testContext() | |
| 218 | + | ||
| 219 | + | // Pre-populate cache | |
| 220 | + | svc.targets["app.example.com"] = &domain.ProxyTarget{ | |
| 221 | + | Host: "192.168.1.100", | |
| 222 | + | Port: 8080, | |
| 223 | + | ContainerID: "old-container", | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Create handler with service as invalidator | |
| 227 | + | handler := NewContainerDeployedHandler(ctx, svc) | |
| 228 | + | ||
| 229 | + | // Simulate container deployed event | |
| 230 | + | event := domain.Event{ | |
| 231 | + | ID: "event-123", | |
| 232 | + | Type: domain.EventContainerDeployed, | |
| 233 | + | Route: "app.example.com", | |
| 234 | + | Data: &domain.ContainerEventPayload{ | |
| 235 | + | ContainerID: "new-container", | |
| 236 | + | Domain: "app.example.com", | |
| 237 | + | }, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | err := handler.Handle(event) | |
| 241 | + | ||
| 242 | + | assert.NoError(t, err) | |
| 243 | + | ||
| 244 | + | // Verify target was invalidated | |
| 245 | + | svc.mu.RLock() | |
| 246 | + | _, exists := svc.targets["app.example.com"] | |
| 247 | + | svc.mu.RUnlock() | |
| 248 | + | ||
| 249 | + | assert.False(t, exists, "cache should be invalidated after container deployed event") | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) { | |
| 253 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 254 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 255 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 256 | + | ||
| 257 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 258 | + | ctx := testContext() | |
| 259 | + | ||
| 260 | + | handler := NewContainerDeployedHandler(ctx, svc) | |
| 261 | + | ||
| 262 | + | // Event with no domain | |
| 263 | + | event := domain.Event{ | |
| 264 | + | ID: "event-123", | |
| 265 | + | Type: domain.EventContainerDeployed, | |
| 266 | + | Data: &domain.ContainerEventPayload{ | |
| 267 | + | ContainerID: "new-container", | |
| 268 | + | }, | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Should not error, just skip | |
| 272 | + | err := handler.Handle(event) | |
| 273 | + | assert.NoError(t, err) | |
| 274 | + | } | |
| @@ -173,3 +173,102 @@ func TestService_isRunningInContainer(t *testing.T) { | |||||
| 173 | result := svc.isRunningInContainer() | 173 | result := svc.isRunningInContainer() | ||
| 174 | assert.IsType(t, true, result) // Just verify it returns a bool | 174 | assert.IsType(t, true, result) // Just verify it returns a bool | ||
| 175 | } | 175 | } | ||
| 176 | + | ||||
| 177 | + | func TestService_InvalidateTarget(t *testing.T) { | |||
| 178 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 179 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 180 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 181 | + | ||||
| 182 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 183 | + | ctx := testContext() | |||
| 184 | + | ||||
| 185 | + | // Pre-populate cache with target | |||
| 186 | + | svc.targets["app.example.com"] = &domain.ProxyTarget{ | |||
| 187 | + | Host: "192.168.1.100", | |||
| 188 | + | Port: 8080, | |||
| 189 | + | ContainerID: "old-container", | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // Invalidate the target | |||
| 193 | + | svc.InvalidateTarget(ctx, "app.example.com") | |||
| 194 | + | ||||
| 195 | + | // Verify target is removed from cache | |||
| 196 | + | svc.mu.RLock() | |||
| 197 | + | _, exists := svc.targets["app.example.com"] | |||
| 198 | + | svc.mu.RUnlock() | |||
| 199 | + | ||||
| 200 | + | assert.False(t, exists, "target should be removed from cache after invalidation") | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestContainerDeployedHandler_CanHandle(t *testing.T) { | |||
| 204 | + | handler := NewContainerDeployedHandler(testContext(), nil) | |||
| 205 | + | ||||
| 206 | + | assert.True(t, handler.CanHandle(domain.EventContainerDeployed)) | |||
| 207 | + | assert.False(t, handler.CanHandle(domain.EventImagePushed)) | |||
| 208 | + | assert.False(t, handler.CanHandle(domain.EventConfigReload)) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestContainerDeployedHandler_Handle_InvalidatesCache(t *testing.T) { | |||
| 212 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 213 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 214 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 215 | + | ||||
| 216 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 217 | + | ctx := testContext() | |||
| 218 | + | ||||
| 219 | + | // Pre-populate cache | |||
| 220 | + | svc.targets["app.example.com"] = &domain.ProxyTarget{ | |||
| 221 | + | Host: "192.168.1.100", | |||
| 222 | + | Port: 8080, | |||
| 223 | + | ContainerID: "old-container", | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Create handler with service as invalidator | |||
| 227 | + | handler := NewContainerDeployedHandler(ctx, svc) | |||
| 228 | + | ||||
| 229 | + | // Simulate container deployed event | |||
| 230 | + | event := domain.Event{ | |||
| 231 | + | ID: "event-123", | |||
| 232 | + | Type: domain.EventContainerDeployed, | |||
| 233 | + | Route: "app.example.com", | |||
| 234 | + | Data: &domain.ContainerEventPayload{ | |||
| 235 | + | ContainerID: "new-container", | |||
| 236 | + | Domain: "app.example.com", | |||
| 237 | + | }, | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | err := handler.Handle(event) | |||
| 241 | + | ||||
| 242 | + | assert.NoError(t, err) | |||
| 243 | + | ||||
| 244 | + | // Verify target was invalidated | |||
| 245 | + | svc.mu.RLock() | |||
| 246 | + | _, exists := svc.targets["app.example.com"] | |||
| 247 | + | svc.mu.RUnlock() | |||
| 248 | + | ||||
| 249 | + | assert.False(t, exists, "cache should be invalidated after container deployed event") | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) { | |||
| 253 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 254 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 255 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 256 | + | ||||
| 257 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 258 | + | ctx := testContext() | |||
| 259 | + | ||||
| 260 | + | handler := NewContainerDeployedHandler(ctx, svc) | |||
| 261 | + | ||||
| 262 | + | // Event with no domain | |||
| 263 | + | event := domain.Event{ | |||
| 264 | + | ID: "event-123", | |||
| 265 | + | Type: domain.EventContainerDeployed, | |||
| 266 | + | Data: &domain.ContainerEventPayload{ | |||
| 267 | + | ContainerID: "new-container", | |||
| 268 | + | }, | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // Should not error, just skip | |||
| 272 | + | err := handler.Handle(event) | |||
| 273 | + | assert.NoError(t, err) | |||
| 274 | + | } | |||