diff --git a/internal/unifiedresources/change_emission.go b/internal/unifiedresources/change_emission.go index b1f1abb55..5d8b0fef6 100644 --- a/internal/unifiedresources/change_emission.go +++ b/internal/unifiedresources/change_emission.go @@ -255,14 +255,24 @@ func dockerCommandChanged(before, after Resource) bool { } func dockerUpdateStatusChanged(before, after Resource) bool { - var beforeStatus, afterStatus any + var beforeStatus, afterStatus *DockerUpdateStatusMeta if before.Docker != nil { beforeStatus = before.Docker.UpdateStatus } if after.Docker != nil { afterStatus = after.Docker.UpdateStatus } - return !reflect.DeepEqual(beforeStatus, afterStatus) + if beforeStatus == nil || afterStatus == nil { + return beforeStatus != afterStatus + } + // LastChecked is restamped by every periodic update check, so comparing the + // whole struct emitted a no-op docker.updateStatus change row per container + // per check cycle (the resource_changes flooding behind issue #1577). Only + // semantic fields count as change. + return beforeStatus.UpdateAvailable != afterStatus.UpdateAvailable || + beforeStatus.CurrentDigest != afterStatus.CurrentDigest || + beforeStatus.LatestDigest != afterStatus.LatestDigest || + beforeStatus.Error != afterStatus.Error } func dockerRestartChanged(before, after Resource) bool { diff --git a/internal/unifiedresources/change_emission_test.go b/internal/unifiedresources/change_emission_test.go index 53b35e666..d94c38718 100644 --- a/internal/unifiedresources/change_emission_test.go +++ b/internal/unifiedresources/change_emission_test.go @@ -276,6 +276,47 @@ func TestBuildResourceChange_ClassifiesDockerRestartChange(t *testing.T) { } } +func TestBuildResourceChange_IgnoresUpdateStatusLastCheckedOnly(t *testing.T) { + before := Resource{ + ID: "container:1", + Type: ResourceTypeAppContainer, + Name: "container-1", + Status: StatusOnline, + Docker: &DockerData{ + Image: "example/app:1", + UpdateStatus: &DockerUpdateStatusMeta{ + UpdateAvailable: false, + CurrentDigest: "sha256:aaa", + LatestDigest: "sha256:aaa", + LastChecked: time.Date(2026, 7, 14, 6, 0, 0, 0, time.UTC), + }, + }, + } + after := before + refreshed := *before.Docker.UpdateStatus + refreshed.LastChecked = refreshed.LastChecked.Add(2 * time.Hour) + afterDocker := *before.Docker + afterDocker.UpdateStatus = &refreshed + after.Docker = &afterDocker + + if change := buildResourceChange(before, true, after, true, time.Now().UTC(), nil, SourcePulseDiff, ""); change != nil { + t.Fatalf("expected no change for LastChecked-only refresh, got %+v", change) + } + + available := refreshed + available.UpdateAvailable = true + available.LatestDigest = "sha256:bbb" + afterDocker.UpdateStatus = &available + + change := buildResourceChange(before, true, after, true, time.Now().UTC(), nil, SourcePulseDiff, "") + if change == nil { + t.Fatal("expected update-status change when digest/availability changed, got nil") + } + if !sameStringSet(mustChangedFields(t, change), []string{"docker.updateStatus"}) { + t.Fatalf("changedFields = %+v, want docker.updateStatus", mustChangedFields(t, change)) + } +} + func TestBuildResourceChange_ClassifiesKubernetesRestartChange(t *testing.T) { before := Resource{ ID: "pod:1",