detached b32e7e8

fix(preview): address CodeRabbit review feedback

- Derive sweep interval from configured TTL (min 1m, max 1h)
- Reorder orphan teardown: cleanup resources before removing container
- Move ContainerLister interface to boundaries/out
- Don't treat zero Created timestamp as expired
- Use all=true in ListContainers to catch stopped orphans
- Log errors in CollectOrphans instead of silent nil return
commit
b32e7e8fb2f6793b16b0d5c6534883b1a5ad5714
author
brice · · 2026-03-25 06:02 UTC
parent
470ef27
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +28 -16
M internal/app/run.go +16 -5
@@ -587,7 +587,16 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587587 log.Warn().Err(err).Msg("failed to load previews")
588588 }
589589
590- svc.previewService.StartTicker(ctx, 1*time.Hour, func(ctx context.Context, p domain.PreviewRoute) {
590+ // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
591+ sweepInterval := previewConfig.TTL / 2
592+ if sweepInterval > 1*time.Hour {
593+ sweepInterval = 1 * time.Hour
594+ }
595+ if sweepInterval < 1*time.Minute {
596+ sweepInterval = 1 * time.Minute
597+ }
598+
599+ svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
591600 teardownTrackedPreview(ctx, svc, p)
592601 }, func(ctx context.Context) {
593602 gcOrphanedPreviews(ctx, svc, previewConfig)
@@ -636,18 +645,20 @@ func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain
636645 log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
637646 Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
638647
639- // Stop and remove container.
648+ // Stop container first, then clean up domain resources while container
649+ // still exists (so future scans can rediscover it on failure), then remove.
640650 if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
641651 log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
642652 }
643- if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
644- log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
645- }
646653
647654 if orphanDomain != "" {
648655 cleanupOrphanDomainResources(ctx, svc, orphanDomain)
649656 }
650657
658+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
659+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
660+ }
661+
651662 log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
652663 }
653664 }
@@ -587,7 +587,16 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587 log.Warn().Err(err).Msg("failed to load previews")587 log.Warn().Err(err).Msg("failed to load previews")
588 }588 }
589 589
590- svc.previewService.StartTicker(ctx, 1*time.Hour, func(ctx context.Context, p domain.PreviewRoute) {590+ // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
591+ sweepInterval := previewConfig.TTL / 2
592+ if sweepInterval > 1*time.Hour {
593+ sweepInterval = 1 * time.Hour
594+ }
595+ if sweepInterval < 1*time.Minute {
596+ sweepInterval = 1 * time.Minute
597+ }
598+
599+ svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
591 teardownTrackedPreview(ctx, svc, p)600 teardownTrackedPreview(ctx, svc, p)
592 }, func(ctx context.Context) {601 }, func(ctx context.Context) {
593 gcOrphanedPreviews(ctx, svc, previewConfig)602 gcOrphanedPreviews(ctx, svc, previewConfig)
@@ -636,18 +645,20 @@ func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain
636 log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).645 log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
637 Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")646 Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
638 647
639- // Stop and remove container.648+ // Stop container first, then clean up domain resources while container
649+ // still exists (so future scans can rediscover it on failure), then remove.
640 if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {650 if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
641 log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")651 log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
642 }652 }
643- if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
644- log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
645- }
646 653
647 if orphanDomain != "" {654 if orphanDomain != "" {
648 cleanupOrphanDomainResources(ctx, svc, orphanDomain)655 cleanupOrphanDomainResources(ctx, svc, orphanDomain)
649 }656 }
650 657
658+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
659+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
660+ }
661+
651 log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")662 log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
652 }663 }
653 }664 }
M internal/boundaries/out/runtime.go +5 -0
@@ -78,6 +78,11 @@ type ContainerRuntime interface {
7878 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
7979 }
8080
81+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
82+type ContainerLister interface {
83+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
84+}
85+
8186 // ExecResult holds the result of executing a command in a container.
8287 type ExecResult struct {
8388 ExitCode int
@@ -78,6 +78,11 @@ type ContainerRuntime interface {
78 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error78 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
79 }79 }
80 80
81+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
82+type ContainerLister interface {
83+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
84+}
85+
81 // ExecResult holds the result of executing a command in a container.86 // ExecResult holds the result of executing a command in a container.
82 type ExecResult struct {87 type ExecResult struct {
83 ExitCode int88 ExitCode int
M internal/usecase/auto/preview/orphans.go +1 -7
@@ -1,7 +1,6 @@
11 package preview
22
33 import (
4- "context"
54 "path/filepath"
65 "strings"
76 "time"
@@ -11,11 +10,6 @@ import (
1110
1211 const orphanGracePeriod = 5 * time.Minute
1312
14-// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
15-type ContainerLister interface {
16- ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
17-}
18-
1913 // IsOrphanPreview returns true if the container looks like a preview (by image
2014 // tag pattern and domain separator) but is not in the tracked preview list.
2115 // Containers younger than 5 minutes are skipped to avoid racing with
@@ -56,7 +50,7 @@ func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatt
5650 // its creation time.
5751 func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
5852 if c.Created.IsZero() {
59- return true // no creation time — treat as expired
53+ return false // unknown creation time — don't delete, play it safe
6054 }
6155 return time.Since(c.Created) > ttl
6256 }
@@ -1,7 +1,6 @@
1 package preview1 package preview
2 2
3 import (3 import (
4- "context"
5 "path/filepath"4 "path/filepath"
6 "strings"5 "strings"
7 "time"6 "time"
@@ -11,11 +10,6 @@ import (
11 10
12 const orphanGracePeriod = 5 * time.Minute11 const orphanGracePeriod = 5 * time.Minute
13 12
14-// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
15-type ContainerLister interface {
16- ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
17-}
18-
19 // IsOrphanPreview returns true if the container looks like a preview (by image13 // IsOrphanPreview returns true if the container looks like a preview (by image
20 // tag pattern and domain separator) but is not in the tracked preview list.14 // tag pattern and domain separator) but is not in the tracked preview list.
21 // Containers younger than 5 minutes are skipped to avoid racing with15 // Containers younger than 5 minutes are skipped to avoid racing with
@@ -56,7 +50,7 @@ func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatt
56 // its creation time.50 // its creation time.
57 func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {51 func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
58 if c.Created.IsZero() {52 if c.Created.IsZero() {
59- return true // no creation time — treat as expired53+ return false // unknown creation time — don't delete, play it safe
60 }54 }
61 return time.Since(c.Created) > ttl55 return time.Since(c.Created) > ttl
62 }56 }
M internal/usecase/auto/preview/orphans_test.go +2 -2
@@ -130,9 +130,9 @@ func TestIsExpiredOrphan(t *testing.T) {
130130 want: false,
131131 },
132132 {
133- name: "zero time — treat as expired",
133+ name: "zero time — unknown, skip to be safe",
134134 created: time.Time{},
135- want: true,
135+ want: false,
136136 },
137137 }
138138
@@ -130,9 +130,9 @@ func TestIsExpiredOrphan(t *testing.T) {
130 want: false,130 want: false,
131 },131 },
132 {132 {
133- name: "zero time — treat as expired",133+ name: "zero time — unknown, skip to be safe",
134 created: time.Time{},134 created: time.Time{},
135- want: true,135+ want: false,
136 },136 },
137 }137 }
138 138
M internal/usecase/auto/preview/service.go +4 -2
@@ -228,9 +228,11 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
228228 // CollectOrphans lists Docker containers and returns those that look like
229229 // expired preview orphans (not tracked, matching tag pattern + separator,
230230 // past TTL based on container creation time).
231-func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232- containers, err := lister.ListContainers(ctx, false)
231+func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232+ containers, err := lister.ListContainers(ctx, true)
233233 if err != nil {
234+ log := zerowrap.FromCtx(ctx)
235+ log.Warn().Err(err).Msg("orphan GC: failed to list containers")
234236 return nil
235237 }
236238
@@ -228,9 +228,11 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
228 // CollectOrphans lists Docker containers and returns those that look like228 // CollectOrphans lists Docker containers and returns those that look like
229 // expired preview orphans (not tracked, matching tag pattern + separator,229 // expired preview orphans (not tracked, matching tag pattern + separator,
230 // past TTL based on container creation time).230 // past TTL based on container creation time).
231-func (s *Service) CollectOrphans(ctx context.Context, lister ContainerLister, tagPatterns []string, separator string) []*domain.Container {231+func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232- containers, err := lister.ListContainers(ctx, false)232+ containers, err := lister.ListContainers(ctx, true)
233 if err != nil {233 if err != nil {
234+ log := zerowrap.FromCtx(ctx)
235+ log.Warn().Err(err).Msg("orphan GC: failed to list containers")
234 return nil236 return nil
235 }237 }
236 238

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