detached 92cef49

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 {
179179 return nil
180180 }
181181
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+
182191 // RefreshTargets refreshes all proxy targets from container state.
183192 func (s *Service) RefreshTargets(ctx context.Context) error {
184193 s.mu.Lock()
@@ -179,6 +179,15 @@ func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
179 return nil179 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) {
173173 result := svc.isRunningInContainer()
174174 assert.IsType(t, true, result) // Just verify it returns a bool
175175 }
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 bool174 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close