detached 4363057

fix(concurrency): fix lock scoping in container service and event bus

Move HealthCheck and SyncContainers lock acquisition to method-level scoping
to prevent holding locks unnecessarily during long-running operations.
Improve event bus error handling and ensure attachment cleanup occurs reliably.
commit
436305710ec07fee08496f7ed12f4c48b9d0f5d3
author
brice · · 2026-02-06 05:32 UTC
parent
02b51ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +85 -36
M internal/adapters/out/eventbus/inmemory.go +30 -24
@@ -74,7 +74,15 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
7474 return nil
7575 case <-bus.ctx.Done():
7676 return fmt.Errorf("event bus is stopped")
77- default:
77+ case <-time.After(5 * time.Second):
78+ bus.log.Error().
79+ Str(zerowrap.FieldLayer, "adapter").
80+ Str(zerowrap.FieldAdapter, "eventbus").
81+ Str("event_id", event.ID).
82+ Str(zerowrap.FieldEvent, string(event.Type)).
83+ Str("image_name", event.ImageName).
84+ Str("tag", event.Tag).
85+ Msg("event channel is full, dropping event after 5s timeout")
7886 return fmt.Errorf("event channel is full, dropping event %s", event.ID)
7987 }
8088 }
@@ -178,29 +186,27 @@ func (bus *InMemory) handleEvent(event domain.Event) {
178186
179187 for _, handler := range handlers {
180188 if handler.CanHandle(event.Type) {
181- go func(h out.EventHandler) {
182- start := time.Now()
183-
184- if err := h.Handle(event); err != nil {
185- bus.log.Error().
186- Str(zerowrap.FieldLayer, "adapter").
187- Str(zerowrap.FieldAdapter, "eventbus").
188- Err(err).
189- Str("event_id", event.ID).
190- Str(zerowrap.FieldEvent, string(event.Type)).
191- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
192- Msg("error handling event")
193- } else {
194- bus.log.Debug().
195- Str(zerowrap.FieldLayer, "adapter").
196- Str(zerowrap.FieldAdapter, "eventbus").
197- Str("event_id", event.ID).
198- Str(zerowrap.FieldEvent, string(event.Type)).
199- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
200- Dur(zerowrap.FieldDuration, time.Since(start)).
201- Msg("event handled successfully")
202- }
203- }(handler)
189+ start := time.Now()
190+
191+ if err := handler.Handle(event); err != nil {
192+ bus.log.Error().
193+ Str(zerowrap.FieldLayer, "adapter").
194+ Str(zerowrap.FieldAdapter, "eventbus").
195+ Err(err).
196+ Str("event_id", event.ID).
197+ Str(zerowrap.FieldEvent, string(event.Type)).
198+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
199+ Msg("error handling event")
200+ } else {
201+ bus.log.Debug().
202+ Str(zerowrap.FieldLayer, "adapter").
203+ Str(zerowrap.FieldAdapter, "eventbus").
204+ Str("event_id", event.ID).
205+ Str(zerowrap.FieldEvent, string(event.Type)).
206+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
207+ Dur(zerowrap.FieldDuration, time.Since(start)).
208+ Msg("event handled successfully")
209+ }
204210 }
205211 }
206212 }
@@ -74,7 +74,15 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
74 return nil74 return nil
75 case <-bus.ctx.Done():75 case <-bus.ctx.Done():
76 return fmt.Errorf("event bus is stopped")76 return fmt.Errorf("event bus is stopped")
77- default:77+ case <-time.After(5 * time.Second):
78+ bus.log.Error().
79+ Str(zerowrap.FieldLayer, "adapter").
80+ Str(zerowrap.FieldAdapter, "eventbus").
81+ Str("event_id", event.ID).
82+ Str(zerowrap.FieldEvent, string(event.Type)).
83+ Str("image_name", event.ImageName).
84+ Str("tag", event.Tag).
85+ Msg("event channel is full, dropping event after 5s timeout")
78 return fmt.Errorf("event channel is full, dropping event %s", event.ID)86 return fmt.Errorf("event channel is full, dropping event %s", event.ID)
79 }87 }
80 }88 }
@@ -178,29 +186,27 @@ func (bus *InMemory) handleEvent(event domain.Event) {
178 186
179 for _, handler := range handlers {187 for _, handler := range handlers {
180 if handler.CanHandle(event.Type) {188 if handler.CanHandle(event.Type) {
181- go func(h out.EventHandler) {189+ start := time.Now()
182- start := time.Now()190+
183-191+ if err := handler.Handle(event); err != nil {
184- if err := h.Handle(event); err != nil {192+ bus.log.Error().
185- bus.log.Error().193+ Str(zerowrap.FieldLayer, "adapter").
186- Str(zerowrap.FieldLayer, "adapter").194+ Str(zerowrap.FieldAdapter, "eventbus").
187- Str(zerowrap.FieldAdapter, "eventbus").195+ Err(err).
188- Err(err).196+ Str("event_id", event.ID).
189- Str("event_id", event.ID).197+ Str(zerowrap.FieldEvent, string(event.Type)).
190- Str(zerowrap.FieldEvent, string(event.Type)).198+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
191- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).199+ Msg("error handling event")
192- Msg("error handling event")200+ } else {
193- } else {201+ bus.log.Debug().
194- bus.log.Debug().202+ Str(zerowrap.FieldLayer, "adapter").
195- Str(zerowrap.FieldLayer, "adapter").203+ Str(zerowrap.FieldAdapter, "eventbus").
196- Str(zerowrap.FieldAdapter, "eventbus").204+ Str("event_id", event.ID).
197- Str("event_id", event.ID).205+ Str(zerowrap.FieldEvent, string(event.Type)).
198- Str(zerowrap.FieldEvent, string(event.Type)).206+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
199- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).207+ Dur(zerowrap.FieldDuration, time.Since(start)).
200- Dur(zerowrap.FieldDuration, time.Since(start)).208+ Msg("event handled successfully")
201- Msg("event handled successfully")209+ }
202- }
203- }(handler)
204 }210 }
205 }211 }
206 }212 }
M internal/usecase/container/service.go +55 -12
@@ -213,7 +213,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
213213
214214 // Deploy attachments BEFORE main container (dependencies first)
215215 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
216- log.WrapErr(err, "failed to deploy some attachments")
216+ return nil, log.WrapErr(err, "failed to deploy attachments")
217217 }
218218
219219 // Get exposed ports (use actual image ref from pull)
@@ -371,8 +371,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
371371 }
372372 }
373373
374- // Find domain for cleanup
374+ // Find domain and attachment IDs for cleanup
375375 var containerDomain string
376+ var attachmentIDs []string
376377 s.mu.RLock()
377378 for d, c := range s.containers {
378379 if c.ID == containerID {
@@ -380,6 +381,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
380381 break
381382 }
382383 }
384+ if containerDomain != "" {
385+ attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
386+ }
383387 s.mu.RUnlock()
384388
385389 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
@@ -393,6 +397,21 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
393397 }
394398 }
395399
400+ // Clean up attachment containers
401+ for _, attachID := range attachmentIDs {
402+ if s.logWriter != nil {
403+ if err := s.logWriter.StopLogging(attachID); err != nil {
404+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
405+ }
406+ }
407+ if err := s.runtime.StopContainer(ctx, attachID); err != nil {
408+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
409+ }
410+ if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
411+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
412+ }
413+ }
414+
396415 // Remove from tracking
397416 s.mu.Lock()
398417 var removedDomain string
@@ -404,6 +423,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
404423 break
405424 }
406425 }
426+ if removedDomain != "" {
427+ delete(s.attachments, removedDomain)
428+ }
407429 s.mu.Unlock()
408430
409431 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
@@ -532,14 +554,19 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
532554 })
533555 log := zerowrap.FromCtx(ctx)
534556
557+ // Copy container IDs under lock, then release before making Docker API calls
535558 s.mu.RLock()
536- defer s.mu.RUnlock()
537-
538- health := make(map[string]bool, len(s.containers))
559+ snapshot := make(map[string]string, len(s.containers))
539560 for d, c := range s.containers {
540- running, err := s.runtime.IsContainerRunning(ctx, c.ID)
561+ snapshot[d] = c.ID
562+ }
563+ s.mu.RUnlock()
564+
565+ health := make(map[string]bool, len(snapshot))
566+ for d, id := range snapshot {
567+ running, err := s.runtime.IsContainerRunning(ctx, id)
541568 if err != nil {
542- log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
569+ log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
543570 health[d] = false
544571 } else {
545572 health[d] = running
@@ -556,9 +583,7 @@ func (s *Service) SyncContainers(ctx context.Context) error {
556583 })
557584 log := zerowrap.FromCtx(ctx)
558585
559- s.mu.Lock()
560- defer s.mu.Unlock()
561-
586+ // List containers without holding lock to avoid blocking during Docker API call
562587 allContainers, err := s.runtime.ListContainers(ctx, false)
563588 if err != nil {
564589 return log.WrapErr(err, "failed to list containers")
@@ -573,7 +598,10 @@ func (s *Service) SyncContainers(ctx context.Context) error {
573598 }
574599 }
575600
601+ s.mu.Lock()
576602 s.containers = managed
603+ s.mu.Unlock()
604+
577605 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
578606 return nil
579607 }
@@ -649,7 +677,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
649677
650678 // UpdateConfig updates the service configuration.
651679 func (s *Service) UpdateConfig(config Config) {
680+ s.mu.Lock()
652681 s.config = config
682+ s.mu.Unlock()
653683 }
654684
655685 // Helper methods
@@ -1138,12 +1168,17 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
11381168 }
11391169
11401170 log := zerowrap.FromCtx(ctx)
1171+ var failed int
11411172 for _, svc := range attachments {
11421173 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
11431174 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1175+ failed++
11441176 }
11451177 }
11461178
1179+ if failed > 0 {
1180+ return fmt.Errorf("failed to deploy %d of %d attachment(s)", failed, len(attachments))
1181+ }
11471182 return nil
11481183 }
11491184
@@ -1557,7 +1592,11 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
15571592 if i == 29 {
15581593 return fmt.Errorf("container did not start within 30 seconds")
15591594 }
1560- time.Sleep(time.Second)
1595+ select {
1596+ case <-time.After(time.Second):
1597+ case <-ctx.Done():
1598+ return ctx.Err()
1599+ }
15611600 }
15621601
15631602 // Additional readiness delay (configurable, default 5 seconds)
@@ -1567,7 +1606,11 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
15671606 }
15681607
15691608 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1570- time.Sleep(delay)
1609+ select {
1610+ case <-time.After(delay):
1611+ case <-ctx.Done():
1612+ return ctx.Err()
1613+ }
15711614
15721615 // Verify still running after delay
15731616 running, err := s.runtime.IsContainerRunning(ctx, containerID)
@@ -213,7 +213,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
213 213
214 // Deploy attachments BEFORE main container (dependencies first)214 // Deploy attachments BEFORE main container (dependencies first)
215 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {215 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
216- log.WrapErr(err, "failed to deploy some attachments")216+ return nil, log.WrapErr(err, "failed to deploy attachments")
217 }217 }
218 218
219 // Get exposed ports (use actual image ref from pull)219 // Get exposed ports (use actual image ref from pull)
@@ -371,8 +371,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
371 }371 }
372 }372 }
373 373
374- // Find domain for cleanup374+ // Find domain and attachment IDs for cleanup
375 var containerDomain string375 var containerDomain string
376+ var attachmentIDs []string
376 s.mu.RLock()377 s.mu.RLock()
377 for d, c := range s.containers {378 for d, c := range s.containers {
378 if c.ID == containerID {379 if c.ID == containerID {
@@ -380,6 +381,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
380 break381 break
381 }382 }
382 }383 }
384+ if containerDomain != "" {
385+ attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
386+ }
383 s.mu.RUnlock()387 s.mu.RUnlock()
384 388
385 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {389 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
@@ -393,6 +397,21 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
393 }397 }
394 }398 }
395 399
400+ // Clean up attachment containers
401+ for _, attachID := range attachmentIDs {
402+ if s.logWriter != nil {
403+ if err := s.logWriter.StopLogging(attachID); err != nil {
404+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
405+ }
406+ }
407+ if err := s.runtime.StopContainer(ctx, attachID); err != nil {
408+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
409+ }
410+ if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
411+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
412+ }
413+ }
414+
396 // Remove from tracking415 // Remove from tracking
397 s.mu.Lock()416 s.mu.Lock()
398 var removedDomain string417 var removedDomain string
@@ -404,6 +423,9 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
404 break423 break
405 }424 }
406 }425 }
426+ if removedDomain != "" {
427+ delete(s.attachments, removedDomain)
428+ }
407 s.mu.Unlock()429 s.mu.Unlock()
408 430
409 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:431 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
@@ -532,14 +554,19 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
532 })554 })
533 log := zerowrap.FromCtx(ctx)555 log := zerowrap.FromCtx(ctx)
534 556
557+ // Copy container IDs under lock, then release before making Docker API calls
535 s.mu.RLock()558 s.mu.RLock()
536- defer s.mu.RUnlock()559+ snapshot := make(map[string]string, len(s.containers))
537-
538- health := make(map[string]bool, len(s.containers))
539 for d, c := range s.containers {560 for d, c := range s.containers {
540- running, err := s.runtime.IsContainerRunning(ctx, c.ID)561+ snapshot[d] = c.ID
562+ }
563+ s.mu.RUnlock()
564+
565+ health := make(map[string]bool, len(snapshot))
566+ for d, id := range snapshot {
567+ running, err := s.runtime.IsContainerRunning(ctx, id)
541 if err != nil {568 if err != nil {
542- log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})569+ log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
543 health[d] = false570 health[d] = false
544 } else {571 } else {
545 health[d] = running572 health[d] = running
@@ -556,9 +583,7 @@ func (s *Service) SyncContainers(ctx context.Context) error {
556 })583 })
557 log := zerowrap.FromCtx(ctx)584 log := zerowrap.FromCtx(ctx)
558 585
559- s.mu.Lock()586+ // List containers without holding lock to avoid blocking during Docker API call
560- defer s.mu.Unlock()
561-
562 allContainers, err := s.runtime.ListContainers(ctx, false)587 allContainers, err := s.runtime.ListContainers(ctx, false)
563 if err != nil {588 if err != nil {
564 return log.WrapErr(err, "failed to list containers")589 return log.WrapErr(err, "failed to list containers")
@@ -573,7 +598,10 @@ func (s *Service) SyncContainers(ctx context.Context) error {
573 }598 }
574 }599 }
575 600
601+ s.mu.Lock()
576 s.containers = managed602 s.containers = managed
603+ s.mu.Unlock()
604+
577 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")605 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
578 return nil606 return nil
579 }607 }
@@ -649,7 +677,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
649 677
650 // UpdateConfig updates the service configuration.678 // UpdateConfig updates the service configuration.
651 func (s *Service) UpdateConfig(config Config) {679 func (s *Service) UpdateConfig(config Config) {
680+ s.mu.Lock()
652 s.config = config681 s.config = config
682+ s.mu.Unlock()
653 }683 }
654 684
655 // Helper methods685 // Helper methods
@@ -1138,12 +1168,17 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
1138 }1168 }
1139 1169
1140 log := zerowrap.FromCtx(ctx)1170 log := zerowrap.FromCtx(ctx)
1171+ var failed int
1141 for _, svc := range attachments {1172 for _, svc := range attachments {
1142 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {1173 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1143 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})1174 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1175+ failed++
1144 }1176 }
1145 }1177 }
1146 1178
1179+ if failed > 0 {
1180+ return fmt.Errorf("failed to deploy %d of %d attachment(s)", failed, len(attachments))
1181+ }
1147 return nil1182 return nil
1148 }1183 }
1149 1184
@@ -1557,7 +1592,11 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1557 if i == 29 {1592 if i == 29 {
1558 return fmt.Errorf("container did not start within 30 seconds")1593 return fmt.Errorf("container did not start within 30 seconds")
1559 }1594 }
1560- time.Sleep(time.Second)1595+ select {
1596+ case <-time.After(time.Second):
1597+ case <-ctx.Done():
1598+ return ctx.Err()
1599+ }
1561 }1600 }
1562 1601
1563 // Additional readiness delay (configurable, default 5 seconds)1602 // Additional readiness delay (configurable, default 5 seconds)
@@ -1567,7 +1606,11 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1567 }1606 }
1568 1607
1569 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")1608 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1570- time.Sleep(delay)1609+ select {
1610+ case <-time.After(delay):
1611+ case <-ctx.Done():
1612+ return ctx.Err()
1613+ }
1571 1614
1572 // Verify still running after delay1615 // Verify still running after delay
1573 running, err := s.runtime.IsContainerRunning(ctx, containerID)1616 running, err := s.runtime.IsContainerRunning(ctx, containerID)

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