detached e8cb095

fix(backup): seed backup metrics from declared targets without per-archive requests

Report the run start as the last success so live runs and stored backups match after a restart, forget targets that are no longer declared, list the newest archive per volume without a HeadObject per object, and generate the BackupMetrics mock. Docs state the alert limits.
commit
e8cb09562783ff6438d74a68154ac68d0a3b1758
author
brice · · 2026-10-17 06:58 UTC
parent
39593e0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +551 -204
M .mockery.yaml +1 -0
@@ -20,6 +20,7 @@ packages:
2020 SecretFileStore:
2121 ContainerLogWriter:
2222 Metrics:
23+ BackupMetrics:
2324 LogExporter:
2425 ContainerLogStreamer:
2526 TokenStore:
@@ -20,6 +20,7 @@ packages:
20 SecretFileStore:20 SecretFileStore:
21 ContainerLogWriter:21 ContainerLogWriter:
22 Metrics:22 Metrics:
23+ BackupMetrics:
23 LogExporter:24 LogExporter:
24 ContainerLogStreamer:25 ContainerLogStreamer:
25 TokenStore:26 TokenStore:
M docs/config/telemetry.md +6 -4
@@ -166,13 +166,13 @@ Attributes: `name`, `reference`
166166 |--------|------|------|-------------|
167167 | `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) |
168168 | `gordon.backup.duration_seconds` | Histogram | s | Duration of each backup run |
169-| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Unix time of the last successful backup per target |
169+| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Start time (Unix) of the last successful backup per target |
170170 | `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target |
171171 | `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run |
172172
173173 Attributes: `kind` (`database` or `volume`), `app`, `service`, `target` (database name or declared volume name), and `result` (`success` or `failure`, runs only). `next_run_timestamp_seconds` only carries `kind`.
174174
175-At startup Gordon reads the stored dumps and S3 archives to restore each target's last success, so the gauges stay correct across restarts. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.
175+The last success is the start time of the run, the same timestamp the stored dump or archive is named after. At startup Gordon restores it from storage for every declared target and drops targets that are no longer declared, so the gauges survive restarts and removed apps stop being reported. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.
176176
177177 With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backup_runs_total`, `gordon_backup_last_success_timestamp_seconds`, and so on. Example alerts:
178178
@@ -180,10 +180,12 @@ With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backu
180180 # No successful backup of a target for more than 26 hours
181181 time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600
182182
183-# Gordon stopped exporting backup metrics
184-absent_over_time(gordon_backup_next_run_timestamp_seconds[15m])
183+# A host stopped exporting its volume backup schedule (one rule per expected host and kind)
184+absent_over_time(gordon_backup_next_run_timestamp_seconds{host_name="my-host", kind="volume"}[15m])
185185 ```
186186
187+The age alert only covers targets that succeeded at least once: a target that has never succeeded has no last-success series. Watch `gordon_backup_runs_total{result="failure"}` to catch those.
188+
187189 ### Event Bus
188190
189191 | Metric | Type | Unit | Description |
@@ -166,13 +166,13 @@ Attributes: `name`, `reference`
166 |--------|------|------|-------------|166 |--------|------|------|-------------|
167 | `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) |167 | `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) |
168 | `gordon.backup.duration_seconds` | Histogram | s | Duration of each backup run |168 | `gordon.backup.duration_seconds` | Histogram | s | Duration of each backup run |
169-| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Unix time of the last successful backup per target |169+| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Start time (Unix) of the last successful backup per target |
170 | `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target |170 | `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target |
171 | `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run |171 | `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run |
172 172
173 Attributes: `kind` (`database` or `volume`), `app`, `service`, `target` (database name or declared volume name), and `result` (`success` or `failure`, runs only). `next_run_timestamp_seconds` only carries `kind`.173 Attributes: `kind` (`database` or `volume`), `app`, `service`, `target` (database name or declared volume name), and `result` (`success` or `failure`, runs only). `next_run_timestamp_seconds` only carries `kind`.
174 174
175-At startup Gordon reads the stored dumps and S3 archives to restore each target's last success, so the gauges stay correct across restarts. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.175+The last success is the start time of the run, the same timestamp the stored dump or archive is named after. At startup Gordon restores it from storage for every declared target and drops targets that are no longer declared, so the gauges survive restarts and removed apps stop being reported. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.
176 176
177 With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backup_runs_total`, `gordon_backup_last_success_timestamp_seconds`, and so on. Example alerts:177 With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backup_runs_total`, `gordon_backup_last_success_timestamp_seconds`, and so on. Example alerts:
178 178
@@ -180,10 +180,12 @@ With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backu
180 # No successful backup of a target for more than 26 hours180 # No successful backup of a target for more than 26 hours
181 time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600181 time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600
182 182
183-# Gordon stopped exporting backup metrics183+# A host stopped exporting its volume backup schedule (one rule per expected host and kind)
184-absent_over_time(gordon_backup_next_run_timestamp_seconds[15m])184+absent_over_time(gordon_backup_next_run_timestamp_seconds{host_name="my-host", kind="volume"}[15m])
185 ```185 ```
186 186
187+The age alert only covers targets that succeeded at least once: a target that has never succeeded has no last-success series. Watch `gordon_backup_runs_total{result="failure"}` to catch those.
188+
187 ### Event Bus189 ### Event Bus
188 190
189 | Metric | Type | Unit | Description |191 | Metric | Type | Unit | Description |
M internal/adapters/out/s3/volume_backup_storage.go +41 -3
@@ -150,6 +150,42 @@ func (s *VolumeBackupStorage) GetVolumeArchive(ctx context.Context, artifactRef
150150
151151 // ListVolumeArchives lists completed volume archive backups for a domain.
152152 func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string) ([]domain.VolumeBackupJob, error) {
153+ jobs, err := s.listArchiveObjects(ctx, app)
154+ if err != nil {
155+ return nil, err
156+ }
157+ for i := range jobs {
158+ s.hydrateVolumeBackupJobMetadata(ctx, &jobs[i], s.keyOf(jobs[i]))
159+ }
160+ sortVolumeBackupJobs(jobs)
161+ return jobs, nil
162+}
163+
164+// LatestVolumeArchives returns the newest archive of every app volume from
165+// the object listing alone, without one metadata request per object.
166+func (s *VolumeBackupStorage) LatestVolumeArchives(ctx context.Context) ([]domain.VolumeBackupJob, error) {
167+ jobs, err := s.listArchiveObjects(ctx, "")
168+ if err != nil {
169+ return nil, err
170+ }
171+ latest := make(map[string]domain.VolumeBackupJob)
172+ for _, job := range jobs {
173+ key := job.App + "/" + job.VolumeName
174+ if prev, ok := latest[key]; !ok || job.StartedAt.After(prev.StartedAt) {
175+ latest[key] = job
176+ }
177+ }
178+ out := make([]domain.VolumeBackupJob, 0, len(latest))
179+ for _, job := range latest {
180+ out = append(out, job)
181+ }
182+ sortVolumeBackupJobs(out)
183+ return out, nil
184+}
185+
186+// listArchiveObjects lists the archives of one app, or of every app when
187+// app is empty, from object keys and sizes only.
188+func (s *VolumeBackupStorage) listArchiveObjects(ctx context.Context, app string) ([]domain.VolumeBackupJob, error) {
153189 listPrefix := s.appPrefix(app)
154190 jobs := make([]domain.VolumeBackupJob, 0)
155191 var token *string
@@ -173,7 +209,6 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string
173209 if obj.Size != nil {
174210 job.SizeBytes = *obj.Size
175211 }
176- s.hydrateVolumeBackupJobMetadata(ctx, &job, *obj.Key)
177212 jobs = append(jobs, job)
178213 }
179214 if out.IsTruncated == nil || !*out.IsTruncated {
@@ -181,11 +216,14 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string
181216 }
182217 token = out.NextContinuationToken
183218 }
184-
185- sortVolumeBackupJobs(jobs)
186219 return jobs, nil
187220 }
188221
222+// keyOf returns the object key of a listed job.
223+func (s *VolumeBackupStorage) keyOf(job domain.VolumeBackupJob) string {
224+ return strings.TrimPrefix(job.ArtifactRef, "s3://"+s.bucket+"/")
225+}
226+
189227 // DeleteVolumeArchive deletes a volume archive by artifact reference.
190228 func (s *VolumeBackupStorage) hydrateVolumeBackupJobMetadata(ctx context.Context, job *domain.VolumeBackupJob, key string) {
191229 out, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
@@ -150,6 +150,42 @@ func (s *VolumeBackupStorage) GetVolumeArchive(ctx context.Context, artifactRef
150 150
151 // ListVolumeArchives lists completed volume archive backups for a domain.151 // ListVolumeArchives lists completed volume archive backups for a domain.
152 func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string) ([]domain.VolumeBackupJob, error) {152 func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string) ([]domain.VolumeBackupJob, error) {
153+ jobs, err := s.listArchiveObjects(ctx, app)
154+ if err != nil {
155+ return nil, err
156+ }
157+ for i := range jobs {
158+ s.hydrateVolumeBackupJobMetadata(ctx, &jobs[i], s.keyOf(jobs[i]))
159+ }
160+ sortVolumeBackupJobs(jobs)
161+ return jobs, nil
162+}
163+
164+// LatestVolumeArchives returns the newest archive of every app volume from
165+// the object listing alone, without one metadata request per object.
166+func (s *VolumeBackupStorage) LatestVolumeArchives(ctx context.Context) ([]domain.VolumeBackupJob, error) {
167+ jobs, err := s.listArchiveObjects(ctx, "")
168+ if err != nil {
169+ return nil, err
170+ }
171+ latest := make(map[string]domain.VolumeBackupJob)
172+ for _, job := range jobs {
173+ key := job.App + "/" + job.VolumeName
174+ if prev, ok := latest[key]; !ok || job.StartedAt.After(prev.StartedAt) {
175+ latest[key] = job
176+ }
177+ }
178+ out := make([]domain.VolumeBackupJob, 0, len(latest))
179+ for _, job := range latest {
180+ out = append(out, job)
181+ }
182+ sortVolumeBackupJobs(out)
183+ return out, nil
184+}
185+
186+// listArchiveObjects lists the archives of one app, or of every app when
187+// app is empty, from object keys and sizes only.
188+func (s *VolumeBackupStorage) listArchiveObjects(ctx context.Context, app string) ([]domain.VolumeBackupJob, error) {
153 listPrefix := s.appPrefix(app)189 listPrefix := s.appPrefix(app)
154 jobs := make([]domain.VolumeBackupJob, 0)190 jobs := make([]domain.VolumeBackupJob, 0)
155 var token *string191 var token *string
@@ -173,7 +209,6 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string
173 if obj.Size != nil {209 if obj.Size != nil {
174 job.SizeBytes = *obj.Size210 job.SizeBytes = *obj.Size
175 }211 }
176- s.hydrateVolumeBackupJobMetadata(ctx, &job, *obj.Key)
177 jobs = append(jobs, job)212 jobs = append(jobs, job)
178 }213 }
179 if out.IsTruncated == nil || !*out.IsTruncated {214 if out.IsTruncated == nil || !*out.IsTruncated {
@@ -181,11 +216,14 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string
181 }216 }
182 token = out.NextContinuationToken217 token = out.NextContinuationToken
183 }218 }
184-
185- sortVolumeBackupJobs(jobs)
186 return jobs, nil219 return jobs, nil
187 }220 }
188 221
222+// keyOf returns the object key of a listed job.
223+func (s *VolumeBackupStorage) keyOf(job domain.VolumeBackupJob) string {
224+ return strings.TrimPrefix(job.ArtifactRef, "s3://"+s.bucket+"/")
225+}
226+
189 // DeleteVolumeArchive deletes a volume archive by artifact reference.227 // DeleteVolumeArchive deletes a volume archive by artifact reference.
190 func (s *VolumeBackupStorage) hydrateVolumeBackupJobMetadata(ctx context.Context, job *domain.VolumeBackupJob, key string) {228 func (s *VolumeBackupStorage) hydrateVolumeBackupJobMetadata(ctx context.Context, job *domain.VolumeBackupJob, key string) {
191 out, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})229 out, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
M internal/adapters/out/s3/volume_backup_storage_test.go +34 -0
@@ -22,6 +22,7 @@ type fakeVolumeS3Client struct {
2222 objects map[string][]byte
2323 metadata map[string]map[string]string
2424 deleted []string
25+ heads int
2526 }
2627
2728 func newFakeVolumeS3Client() *fakeVolumeS3Client {
@@ -34,6 +35,7 @@ func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjec
3435 }
3536
3637 func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
38+ f.heads++
3739 return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
3840 }
3941
@@ -166,6 +168,38 @@ func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
166168 assert.Equal(t, []string{longID}, client.deleted)
167169 }
168170
171+// TestVolumeBackupStorageLatestVolumeArchives proves the newest archive of
172+// each app volume is selected from the listing alone, with no per-object
173+// metadata request.
174+func TestVolumeBackupStorageLatestVolumeArchives(t *testing.T) {
175+ client := newFakeVolumeS3Client()
176+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, nil)
177+ for key, data := range map[string]string{
178+ "gordon/apps/forgejo/volumes/forgejo-data/20261017T013000Z-1a2b3c4d.tar.gz": "old",
179+ "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-5e6f7a8b.tar.gz": "newest",
180+ "gordon/apps/forgejo/volumes/postgres-data/20261016T013000Z-0a0b0c0d.tar.gz": "pg",
181+ "gordon/apps/ermite/volumes/forgejo-data/20261015T013000Z-9a9b9c9d.tar.gz": "other-app",
182+ } {
183+ client.objects[key] = []byte(data)
184+ }
185+
186+ jobs, err := storage.LatestVolumeArchives(context.Background())
187+ require.NoError(t, err)
188+ assert.Equal(t, 0, client.heads)
189+
190+ got := map[string]domain.VolumeBackupJob{}
191+ for _, job := range jobs {
192+ got[job.App+"/"+job.VolumeName] = job
193+ }
194+ require.Len(t, got, 3)
195+ newest := got["forgejo/forgejo-data"]
196+ assert.Equal(t, "5e6f7a8b", newest.ID)
197+ assert.Equal(t, int64(len("newest")), newest.SizeBytes)
198+ assert.Equal(t, time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC), newest.StartedAt)
199+ assert.Contains(t, got, "forgejo/postgres-data")
200+ assert.Contains(t, got, "ermite/forgejo-data")
201+}
202+
169203 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
170204 client := newFakeVolumeS3Client()
171205 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
@@ -22,6 +22,7 @@ type fakeVolumeS3Client struct {
22 objects map[string][]byte22 objects map[string][]byte
23 metadata map[string]map[string]string23 metadata map[string]map[string]string
24 deleted []string24 deleted []string
25+ heads int
25 }26 }
26 27
27 func newFakeVolumeS3Client() *fakeVolumeS3Client {28 func newFakeVolumeS3Client() *fakeVolumeS3Client {
@@ -34,6 +35,7 @@ func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjec
34 }35 }
35 36
36 func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {37 func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
38+ f.heads++
37 return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil39 return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
38 }40 }
39 41
@@ -166,6 +168,38 @@ func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
166 assert.Equal(t, []string{longID}, client.deleted)168 assert.Equal(t, []string{longID}, client.deleted)
167 }169 }
168 170
171+// TestVolumeBackupStorageLatestVolumeArchives proves the newest archive of
172+// each app volume is selected from the listing alone, with no per-object
173+// metadata request.
174+func TestVolumeBackupStorageLatestVolumeArchives(t *testing.T) {
175+ client := newFakeVolumeS3Client()
176+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, nil)
177+ for key, data := range map[string]string{
178+ "gordon/apps/forgejo/volumes/forgejo-data/20261017T013000Z-1a2b3c4d.tar.gz": "old",
179+ "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-5e6f7a8b.tar.gz": "newest",
180+ "gordon/apps/forgejo/volumes/postgres-data/20261016T013000Z-0a0b0c0d.tar.gz": "pg",
181+ "gordon/apps/ermite/volumes/forgejo-data/20261015T013000Z-9a9b9c9d.tar.gz": "other-app",
182+ } {
183+ client.objects[key] = []byte(data)
184+ }
185+
186+ jobs, err := storage.LatestVolumeArchives(context.Background())
187+ require.NoError(t, err)
188+ assert.Equal(t, 0, client.heads)
189+
190+ got := map[string]domain.VolumeBackupJob{}
191+ for _, job := range jobs {
192+ got[job.App+"/"+job.VolumeName] = job
193+ }
194+ require.Len(t, got, 3)
195+ newest := got["forgejo/forgejo-data"]
196+ assert.Equal(t, "5e6f7a8b", newest.ID)
197+ assert.Equal(t, int64(len("newest")), newest.SizeBytes)
198+ assert.Equal(t, time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC), newest.StartedAt)
199+ assert.Contains(t, got, "forgejo/postgres-data")
200+ assert.Contains(t, got, "ermite/forgejo-data")
201+}
202+
169 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {203 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
170 client := newFakeVolumeS3Client()204 client := newFakeVolumeS3Client()
171 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})205 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
M internal/adapters/out/telemetry/backup_metrics.go +34 -26
@@ -14,20 +14,12 @@ import (
1414
1515 var _ out.BackupMetrics = (*Metrics)(nil)
1616
17-// backupTargetKey identifies one backed-up resource.
18-type backupTargetKey struct {
19- kind domain.BackupKind
20- app string
21- service string
22- target string
23-}
24-
25-func (k backupTargetKey) attrs() []attribute.KeyValue {
17+func targetAttrs(ref domain.BackupTargetRef) []attribute.KeyValue {
2618 return []attribute.KeyValue{
27- attribute.String("kind", string(k.kind)),
28- attribute.String("app", k.app),
29- attribute.String("service", k.service),
30- attribute.String("target", k.target),
19+ attribute.String("kind", string(ref.Kind)),
20+ attribute.String("app", ref.App),
21+ attribute.String("service", ref.Service),
22+ attribute.String("target", ref.Target),
3123 }
3224 }
3325
@@ -40,33 +32,44 @@ type backupLastSuccess struct {
4032 // gauges at each collection.
4133 type backupState struct {
4234 mu sync.Mutex
43- last map[backupTargetKey]backupLastSuccess
35+ last map[domain.BackupTargetRef]backupLastSuccess
4436 nextRun map[domain.BackupKind]func() time.Time
4537 }
4638
4739 func newBackupState() *backupState {
4840 return &backupState{
49- last: map[backupTargetKey]backupLastSuccess{},
41+ last: map[domain.BackupTargetRef]backupLastSuccess{},
5042 nextRun: map[domain.BackupKind]func() time.Time{},
5143 }
5244 }
5345
54-func keyOf(run domain.BackupRunRecord) backupTargetKey {
55- return backupTargetKey{kind: run.Kind, app: run.App, service: run.Service, target: run.Target}
56-}
57-
5846 // remember stores a success unless a newer one is already known.
5947 func (s *backupState) remember(run domain.BackupRunRecord) {
60- if !run.Succeeded || run.FinishedAt.IsZero() {
48+ if !run.Succeeded || run.StartedAt.IsZero() {
6149 return
6250 }
63- key := keyOf(run)
51+ key := run.BackupTargetRef
6452 s.mu.Lock()
6553 defer s.mu.Unlock()
66- if prev, ok := s.last[key]; ok && !run.FinishedAt.After(prev.at) {
54+ if prev, ok := s.last[key]; ok && !run.StartedAt.After(prev.at) {
6755 return
6856 }
69- s.last[key] = backupLastSuccess{at: run.FinishedAt, size: run.SizeBytes}
57+ s.last[key] = backupLastSuccess{at: run.StartedAt, size: run.SizeBytes}
58+}
59+
60+// retain drops the last success of every target of kind not in targets.
61+func (s *backupState) retain(kind domain.BackupKind, targets []domain.BackupTargetRef) {
62+ keep := make(map[domain.BackupTargetRef]struct{}, len(targets))
63+ for _, ref := range targets {
64+ keep[ref] = struct{}{}
65+ }
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ for key := range s.last {
69+ if _, ok := keep[key]; key.Kind == kind && !ok {
70+ delete(s.last, key)
71+ }
72+ }
7073 }
7174
7275 // registerBackupInstruments creates the backup counters, histogram, and
@@ -84,7 +87,7 @@ func (m *Metrics) registerBackupInstruments() error {
8487 return err
8588 }
8689 lastSuccess, err := m.meter.Float64ObservableGauge("gordon.backup.last_success_timestamp_seconds",
87- metric.WithDescription("Unix time of the last successful backup per target"),
90+ metric.WithDescription("Start time (Unix) of the last successful backup per target"),
8891 metric.WithUnit("s"))
8992 if err != nil {
9093 return err
@@ -105,7 +108,7 @@ func (m *Metrics) registerBackupInstruments() error {
105108 m.backups.mu.Lock()
106109 defer m.backups.mu.Unlock()
107110 for key, last := range m.backups.last {
108- attrs := metric.WithAttributes(key.attrs()...)
111+ attrs := metric.WithAttributes(targetAttrs(key)...)
109112 o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs)
110113 o.ObserveFloat64(lastSize, float64(last.size), attrs)
111114 }
@@ -125,7 +128,7 @@ func (m *Metrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecor
125128 if run.Succeeded {
126129 result = "success"
127130 }
128- attrs := keyOf(run).attrs()
131+ attrs := targetAttrs(run.BackupTargetRef)
129132 m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...))
130133 m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...))
131134 m.backups.remember(run)
@@ -136,6 +139,11 @@ func (m *Metrics) ObserveStoredBackup(run domain.BackupRunRecord) {
136139 m.backups.remember(run)
137140 }
138141
142+// RetainBackupTargets implements out.BackupMetrics.
143+func (m *Metrics) RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef) {
144+ m.backups.retain(kind, targets)
145+}
146+
139147 // ObserveBackupNextRun exports the next planned run of one backup kind.
140148 // next is called at each collection; a zero time is not exported.
141149 func (m *Metrics) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
@@ -14,20 +14,12 @@ import (
14 14
15 var _ out.BackupMetrics = (*Metrics)(nil)15 var _ out.BackupMetrics = (*Metrics)(nil)
16 16
17-// backupTargetKey identifies one backed-up resource.17+func targetAttrs(ref domain.BackupTargetRef) []attribute.KeyValue {
18-type backupTargetKey struct {
19- kind domain.BackupKind
20- app string
21- service string
22- target string
23-}
24-
25-func (k backupTargetKey) attrs() []attribute.KeyValue {
26 return []attribute.KeyValue{18 return []attribute.KeyValue{
27- attribute.String("kind", string(k.kind)),19+ attribute.String("kind", string(ref.Kind)),
28- attribute.String("app", k.app),20+ attribute.String("app", ref.App),
29- attribute.String("service", k.service),21+ attribute.String("service", ref.Service),
30- attribute.String("target", k.target),22+ attribute.String("target", ref.Target),
31 }23 }
32 }24 }
33 25
@@ -40,33 +32,44 @@ type backupLastSuccess struct {
40 // gauges at each collection.32 // gauges at each collection.
41 type backupState struct {33 type backupState struct {
42 mu sync.Mutex34 mu sync.Mutex
43- last map[backupTargetKey]backupLastSuccess35+ last map[domain.BackupTargetRef]backupLastSuccess
44 nextRun map[domain.BackupKind]func() time.Time36 nextRun map[domain.BackupKind]func() time.Time
45 }37 }
46 38
47 func newBackupState() *backupState {39 func newBackupState() *backupState {
48 return &backupState{40 return &backupState{
49- last: map[backupTargetKey]backupLastSuccess{},41+ last: map[domain.BackupTargetRef]backupLastSuccess{},
50 nextRun: map[domain.BackupKind]func() time.Time{},42 nextRun: map[domain.BackupKind]func() time.Time{},
51 }43 }
52 }44 }
53 45
54-func keyOf(run domain.BackupRunRecord) backupTargetKey {
55- return backupTargetKey{kind: run.Kind, app: run.App, service: run.Service, target: run.Target}
56-}
57-
58 // remember stores a success unless a newer one is already known.46 // remember stores a success unless a newer one is already known.
59 func (s *backupState) remember(run domain.BackupRunRecord) {47 func (s *backupState) remember(run domain.BackupRunRecord) {
60- if !run.Succeeded || run.FinishedAt.IsZero() {48+ if !run.Succeeded || run.StartedAt.IsZero() {
61 return49 return
62 }50 }
63- key := keyOf(run)51+ key := run.BackupTargetRef
64 s.mu.Lock()52 s.mu.Lock()
65 defer s.mu.Unlock()53 defer s.mu.Unlock()
66- if prev, ok := s.last[key]; ok && !run.FinishedAt.After(prev.at) {54+ if prev, ok := s.last[key]; ok && !run.StartedAt.After(prev.at) {
67 return55 return
68 }56 }
69- s.last[key] = backupLastSuccess{at: run.FinishedAt, size: run.SizeBytes}57+ s.last[key] = backupLastSuccess{at: run.StartedAt, size: run.SizeBytes}
58+}
59+
60+// retain drops the last success of every target of kind not in targets.
61+func (s *backupState) retain(kind domain.BackupKind, targets []domain.BackupTargetRef) {
62+ keep := make(map[domain.BackupTargetRef]struct{}, len(targets))
63+ for _, ref := range targets {
64+ keep[ref] = struct{}{}
65+ }
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ for key := range s.last {
69+ if _, ok := keep[key]; key.Kind == kind && !ok {
70+ delete(s.last, key)
71+ }
72+ }
70 }73 }
71 74
72 // registerBackupInstruments creates the backup counters, histogram, and75 // registerBackupInstruments creates the backup counters, histogram, and
@@ -84,7 +87,7 @@ func (m *Metrics) registerBackupInstruments() error {
84 return err87 return err
85 }88 }
86 lastSuccess, err := m.meter.Float64ObservableGauge("gordon.backup.last_success_timestamp_seconds",89 lastSuccess, err := m.meter.Float64ObservableGauge("gordon.backup.last_success_timestamp_seconds",
87- metric.WithDescription("Unix time of the last successful backup per target"),90+ metric.WithDescription("Start time (Unix) of the last successful backup per target"),
88 metric.WithUnit("s"))91 metric.WithUnit("s"))
89 if err != nil {92 if err != nil {
90 return err93 return err
@@ -105,7 +108,7 @@ func (m *Metrics) registerBackupInstruments() error {
105 m.backups.mu.Lock()108 m.backups.mu.Lock()
106 defer m.backups.mu.Unlock()109 defer m.backups.mu.Unlock()
107 for key, last := range m.backups.last {110 for key, last := range m.backups.last {
108- attrs := metric.WithAttributes(key.attrs()...)111+ attrs := metric.WithAttributes(targetAttrs(key)...)
109 o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs)112 o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs)
110 o.ObserveFloat64(lastSize, float64(last.size), attrs)113 o.ObserveFloat64(lastSize, float64(last.size), attrs)
111 }114 }
@@ -125,7 +128,7 @@ func (m *Metrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecor
125 if run.Succeeded {128 if run.Succeeded {
126 result = "success"129 result = "success"
127 }130 }
128- attrs := keyOf(run).attrs()131+ attrs := targetAttrs(run.BackupTargetRef)
129 m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...))132 m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...))
130 m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...))133 m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...))
131 m.backups.remember(run)134 m.backups.remember(run)
@@ -136,6 +139,11 @@ func (m *Metrics) ObserveStoredBackup(run domain.BackupRunRecord) {
136 m.backups.remember(run)139 m.backups.remember(run)
137 }140 }
138 141
142+// RetainBackupTargets implements out.BackupMetrics.
143+func (m *Metrics) RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef) {
144+ m.backups.retain(kind, targets)
145+}
146+
139 // ObserveBackupNextRun exports the next planned run of one backup kind.147 // ObserveBackupNextRun exports the next planned run of one backup kind.
140 // next is called at each collection; a zero time is not exported.148 // next is called at each collection; a zero time is not exported.
141 func (m *Metrics) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {149 func (m *Metrics) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
M internal/adapters/out/telemetry/backup_metrics_test.go +28 -12
@@ -61,13 +61,12 @@ func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
6161 ctx := context.Background()
6262 done := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
6363
64+ ref := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data"}
6465 m.RecordBackupRun(ctx, domain.BackupRunRecord{
65- Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
66- Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, FinishedAt: done,
66+ BackupTargetRef: ref, Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, StartedAt: done,
6767 })
6868 m.RecordBackupRun(ctx, domain.BackupRunRecord{
69- Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
70- Succeeded: false, Duration: time.Second, FinishedAt: done.Add(time.Hour),
69+ BackupTargetRef: ref, Succeeded: false, Duration: time.Second, StartedAt: done.Add(time.Hour),
7170 })
7271
7372 data := collect(t, reader)
@@ -86,11 +85,9 @@ func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
8685
8786 hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])
8887 require.True(t, ok)
89- var count uint64
90- for _, dp := range hist.DataPoints {
91- count += dp.Count
92- }
93- assert.Equal(t, uint64(2), count)
88+ require.Len(t, hist.DataPoints, 1)
89+ assert.Equal(t, uint64(2), hist.DataPoints[0].Count)
90+ assert.InDelta(t, 41.0, hist.DataPoints[0].Sum, 0.001)
9491
9592 // A failure never moves the last-success timestamp or size.
9693 assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},
@@ -103,11 +100,14 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
103100 m, reader := newTestMetrics(t)
104101 newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
105102 older := newer.Add(-24 * time.Hour)
106- rec := domain.BackupRunRecord{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders", Succeeded: true}
103+ rec := domain.BackupRunRecord{
104+ BackupTargetRef: domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders"},
105+ Succeeded: true,
106+ }
107107
108- rec.FinishedAt, rec.SizeBytes = newer, 200
108+ rec.StartedAt, rec.SizeBytes = newer, 200
109109 m.ObserveStoredBackup(rec)
110- rec.FinishedAt, rec.SizeBytes = older, 100
110+ rec.StartedAt, rec.SizeBytes = older, 100
111111 m.ObserveStoredBackup(rec)
112112
113113 data := collect(t, reader)
@@ -121,6 +121,22 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
121121 assert.False(t, counted)
122122 }
123123
124+func TestRetainBackupTargets_ForgetsUndeclaredTargetsOfThatKindOnly(t *testing.T) {
125+ m, reader := newTestMetrics(t)
126+ at := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
127+ keep := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
128+ gone := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "old", Service: "api", Target: "cache"}
129+ db := domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "old", Service: "db", Target: "orders"}
130+ for _, ref := range []domain.BackupTargetRef{keep, gone, db} {
131+ m.ObserveStoredBackup(domain.BackupRunRecord{BackupTargetRef: ref, Succeeded: true, StartedAt: at})
132+ }
133+
134+ m.RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{keep})
135+
136+ assert.Equal(t, map[string]float64{"data": float64(at.Unix()), "orders": float64(at.Unix())},
137+ gaugeByTarget(t, collect(t, reader)["gordon.backup.last_success_timestamp_seconds"]))
138+}
139+
124140 func TestObserveBackupNextRun(t *testing.T) {
125141 m, reader := newTestMetrics(t)
126142 next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
@@ -61,13 +61,12 @@ func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
61 ctx := context.Background()61 ctx := context.Background()
62 done := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)62 done := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
63 63
64+ ref := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data"}
64 m.RecordBackupRun(ctx, domain.BackupRunRecord{65 m.RecordBackupRun(ctx, domain.BackupRunRecord{
65- Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",66+ BackupTargetRef: ref, Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, StartedAt: done,
66- Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, FinishedAt: done,
67 })67 })
68 m.RecordBackupRun(ctx, domain.BackupRunRecord{68 m.RecordBackupRun(ctx, domain.BackupRunRecord{
69- Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",69+ BackupTargetRef: ref, Succeeded: false, Duration: time.Second, StartedAt: done.Add(time.Hour),
70- Succeeded: false, Duration: time.Second, FinishedAt: done.Add(time.Hour),
71 })70 })
72 71
73 data := collect(t, reader)72 data := collect(t, reader)
@@ -86,11 +85,9 @@ func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
86 85
87 hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])86 hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])
88 require.True(t, ok)87 require.True(t, ok)
89- var count uint6488+ require.Len(t, hist.DataPoints, 1)
90- for _, dp := range hist.DataPoints {89+ assert.Equal(t, uint64(2), hist.DataPoints[0].Count)
91- count += dp.Count90+ assert.InDelta(t, 41.0, hist.DataPoints[0].Sum, 0.001)
92- }
93- assert.Equal(t, uint64(2), count)
94 91
95 // A failure never moves the last-success timestamp or size.92 // A failure never moves the last-success timestamp or size.
96 assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},93 assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},
@@ -103,11 +100,14 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
103 m, reader := newTestMetrics(t)100 m, reader := newTestMetrics(t)
104 newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)101 newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
105 older := newer.Add(-24 * time.Hour)102 older := newer.Add(-24 * time.Hour)
106- rec := domain.BackupRunRecord{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders", Succeeded: true}103+ rec := domain.BackupRunRecord{
104+ BackupTargetRef: domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders"},
105+ Succeeded: true,
106+ }
107 107
108- rec.FinishedAt, rec.SizeBytes = newer, 200108+ rec.StartedAt, rec.SizeBytes = newer, 200
109 m.ObserveStoredBackup(rec)109 m.ObserveStoredBackup(rec)
110- rec.FinishedAt, rec.SizeBytes = older, 100110+ rec.StartedAt, rec.SizeBytes = older, 100
111 m.ObserveStoredBackup(rec)111 m.ObserveStoredBackup(rec)
112 112
113 data := collect(t, reader)113 data := collect(t, reader)
@@ -121,6 +121,22 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
121 assert.False(t, counted)121 assert.False(t, counted)
122 }122 }
123 123
124+func TestRetainBackupTargets_ForgetsUndeclaredTargetsOfThatKindOnly(t *testing.T) {
125+ m, reader := newTestMetrics(t)
126+ at := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
127+ keep := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
128+ gone := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "old", Service: "api", Target: "cache"}
129+ db := domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "old", Service: "db", Target: "orders"}
130+ for _, ref := range []domain.BackupTargetRef{keep, gone, db} {
131+ m.ObserveStoredBackup(domain.BackupRunRecord{BackupTargetRef: ref, Succeeded: true, StartedAt: at})
132+ }
133+
134+ m.RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{keep})
135+
136+ assert.Equal(t, map[string]float64{"data": float64(at.Unix()), "orders": float64(at.Unix())},
137+ gaugeByTarget(t, collect(t, reader)["gordon.backup.last_success_timestamp_seconds"]))
138+}
139+
124 func TestObserveBackupNextRun(t *testing.T) {140 func TestObserveBackupNextRun(t *testing.T) {
125 m, reader := newTestMetrics(t)141 m, reader := newTestMetrics(t)
126 next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)142 next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
M internal/boundaries/out/backup_metrics.go +4 -0
@@ -16,4 +16,8 @@ type BackupMetrics interface {
1616 // without counting a run, so the last success survives a restart.
1717 // Older records never replace a newer one.
1818 ObserveStoredBackup(run domain.BackupRunRecord)
19+ // RetainBackupTargets forgets the last success of every target of kind
20+ // that is not in targets, so a removed app or backup declaration stops
21+ // being reported.
22+ RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef)
1923 }
@@ -16,4 +16,8 @@ type BackupMetrics interface {
16 // without counting a run, so the last success survives a restart.16 // without counting a run, so the last success survives a restart.
17 // Older records never replace a newer one.17 // Older records never replace a newer one.
18 ObserveStoredBackup(run domain.BackupRunRecord)18 ObserveStoredBackup(run domain.BackupRunRecord)
19+ // RetainBackupTargets forgets the last success of every target of kind
20+ // that is not in targets, so a removed app or backup declaration stops
21+ // being reported.
22+ RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef)
19 }23 }
M internal/boundaries/out/backupstore.go +4 -0
@@ -30,6 +30,10 @@ type VolumeBackupStorage interface {
3030 StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error)
3131 GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error)
3232 ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
33+ // LatestVolumeArchives returns the newest archive of every app volume
34+ // from the object listing alone: Service and other per-object metadata
35+ // are left empty.
36+ LatestVolumeArchives(ctx context.Context) ([]domain.VolumeBackupJob, error)
3337 DeleteVolumeArchive(ctx context.Context, artifactRef string) error
3438 ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error)
3539 }
@@ -30,6 +30,10 @@ type VolumeBackupStorage interface {
30 StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error)30 StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error)
31 GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error)31 GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error)
32 ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)32 ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
33+ // LatestVolumeArchives returns the newest archive of every app volume
34+ // from the object listing alone: Service and other per-object metadata
35+ // are left empty.
36+ LatestVolumeArchives(ctx context.Context) ([]domain.VolumeBackupJob, error)
33 DeleteVolumeArchive(ctx context.Context, artifactRef string) error37 DeleteVolumeArchive(ctx context.Context, artifactRef string) error
34 ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error)38 ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error)
35 }39 }
A internal/boundaries/out/mocks/mock_backup_metrics.go +180 -0
@@ -0,0 +1,180 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockBackupMetrics creates a new instance of MockBackupMetrics. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockBackupMetrics(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockBackupMetrics {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MockBackupMetrics{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MockBackupMetrics is an autogenerated mock type for the BackupMetrics type
38+type MockBackupMetrics struct {
39+ mock.Mock
40+}
41+
42+type MockBackupMetrics_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MockBackupMetrics) EXPECT() *MockBackupMetrics_Expecter {
47+ return &MockBackupMetrics_Expecter{mock: &_m.Mock}
48+}
49+
50+// ObserveStoredBackup provides a mock function for the type MockBackupMetrics
51+func (_mock *MockBackupMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {
52+ _mock.Called(run)
53+ return
54+}
55+
56+// MockBackupMetrics_ObserveStoredBackup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveStoredBackup'
57+type MockBackupMetrics_ObserveStoredBackup_Call struct {
58+ *mock.Call
59+}
60+
61+// ObserveStoredBackup is a helper method to define mock.On call
62+// - run domain.BackupRunRecord
63+func (_e *MockBackupMetrics_Expecter) ObserveStoredBackup(run any) *MockBackupMetrics_ObserveStoredBackup_Call {
64+ return &MockBackupMetrics_ObserveStoredBackup_Call{Call: _e.mock.On("ObserveStoredBackup", run)}
65+}
66+
67+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) Run(run func(run domain.BackupRunRecord)) *MockBackupMetrics_ObserveStoredBackup_Call {
68+ _c.Call.Run(func(args mock.Arguments) {
69+ var arg0 domain.BackupRunRecord
70+ if args[0] != nil {
71+ arg0 = args[0].(domain.BackupRunRecord)
72+ }
73+ run(
74+ arg0,
75+ )
76+ })
77+ return _c
78+}
79+
80+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) Return() *MockBackupMetrics_ObserveStoredBackup_Call {
81+ _c.Call.Return()
82+ return _c
83+}
84+
85+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) RunAndReturn(run func(run domain.BackupRunRecord)) *MockBackupMetrics_ObserveStoredBackup_Call {
86+ _c.Run(run)
87+ return _c
88+}
89+
90+// RecordBackupRun provides a mock function for the type MockBackupMetrics
91+func (_mock *MockBackupMetrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecord) {
92+ _mock.Called(ctx, run)
93+ return
94+}
95+
96+// MockBackupMetrics_RecordBackupRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordBackupRun'
97+type MockBackupMetrics_RecordBackupRun_Call struct {
98+ *mock.Call
99+}
100+
101+// RecordBackupRun is a helper method to define mock.On call
102+// - ctx context.Context
103+// - run domain.BackupRunRecord
104+func (_e *MockBackupMetrics_Expecter) RecordBackupRun(ctx any, run any) *MockBackupMetrics_RecordBackupRun_Call {
105+ return &MockBackupMetrics_RecordBackupRun_Call{Call: _e.mock.On("RecordBackupRun", ctx, run)}
106+}
107+
108+func (_c *MockBackupMetrics_RecordBackupRun_Call) Run(run func(ctx context.Context, run domain.BackupRunRecord)) *MockBackupMetrics_RecordBackupRun_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 context.Context
111+ if args[0] != nil {
112+ arg0 = args[0].(context.Context)
113+ }
114+ var arg1 domain.BackupRunRecord
115+ if args[1] != nil {
116+ arg1 = args[1].(domain.BackupRunRecord)
117+ }
118+ run(
119+ arg0,
120+ arg1,
121+ )
122+ })
123+ return _c
124+}
125+
126+func (_c *MockBackupMetrics_RecordBackupRun_Call) Return() *MockBackupMetrics_RecordBackupRun_Call {
127+ _c.Call.Return()
128+ return _c
129+}
130+
131+func (_c *MockBackupMetrics_RecordBackupRun_Call) RunAndReturn(run func(ctx context.Context, run domain.BackupRunRecord)) *MockBackupMetrics_RecordBackupRun_Call {
132+ _c.Run(run)
133+ return _c
134+}
135+
136+// RetainBackupTargets provides a mock function for the type MockBackupMetrics
137+func (_mock *MockBackupMetrics) RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef) {
138+ _mock.Called(kind, targets)
139+ return
140+}
141+
142+// MockBackupMetrics_RetainBackupTargets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetainBackupTargets'
143+type MockBackupMetrics_RetainBackupTargets_Call struct {
144+ *mock.Call
145+}
146+
147+// RetainBackupTargets is a helper method to define mock.On call
148+// - kind domain.BackupKind
149+// - targets []domain.BackupTargetRef
150+func (_e *MockBackupMetrics_Expecter) RetainBackupTargets(kind any, targets any) *MockBackupMetrics_RetainBackupTargets_Call {
151+ return &MockBackupMetrics_RetainBackupTargets_Call{Call: _e.mock.On("RetainBackupTargets", kind, targets)}
152+}
153+
154+func (_c *MockBackupMetrics_RetainBackupTargets_Call) Run(run func(kind domain.BackupKind, targets []domain.BackupTargetRef)) *MockBackupMetrics_RetainBackupTargets_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 domain.BackupKind
157+ if args[0] != nil {
158+ arg0 = args[0].(domain.BackupKind)
159+ }
160+ var arg1 []domain.BackupTargetRef
161+ if args[1] != nil {
162+ arg1 = args[1].([]domain.BackupTargetRef)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *MockBackupMetrics_RetainBackupTargets_Call) Return() *MockBackupMetrics_RetainBackupTargets_Call {
173+ _c.Call.Return()
174+ return _c
175+}
176+
177+func (_c *MockBackupMetrics_RetainBackupTargets_Call) RunAndReturn(run func(kind domain.BackupKind, targets []domain.BackupTargetRef)) *MockBackupMetrics_RetainBackupTargets_Call {
178+ _c.Run(run)
179+ return _c
180+}
@@ -0,0 +1,180 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockBackupMetrics creates a new instance of MockBackupMetrics. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockBackupMetrics(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockBackupMetrics {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MockBackupMetrics{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MockBackupMetrics is an autogenerated mock type for the BackupMetrics type
38+type MockBackupMetrics struct {
39+ mock.Mock
40+}
41+
42+type MockBackupMetrics_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MockBackupMetrics) EXPECT() *MockBackupMetrics_Expecter {
47+ return &MockBackupMetrics_Expecter{mock: &_m.Mock}
48+}
49+
50+// ObserveStoredBackup provides a mock function for the type MockBackupMetrics
51+func (_mock *MockBackupMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {
52+ _mock.Called(run)
53+ return
54+}
55+
56+// MockBackupMetrics_ObserveStoredBackup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveStoredBackup'
57+type MockBackupMetrics_ObserveStoredBackup_Call struct {
58+ *mock.Call
59+}
60+
61+// ObserveStoredBackup is a helper method to define mock.On call
62+// - run domain.BackupRunRecord
63+func (_e *MockBackupMetrics_Expecter) ObserveStoredBackup(run any) *MockBackupMetrics_ObserveStoredBackup_Call {
64+ return &MockBackupMetrics_ObserveStoredBackup_Call{Call: _e.mock.On("ObserveStoredBackup", run)}
65+}
66+
67+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) Run(run func(run domain.BackupRunRecord)) *MockBackupMetrics_ObserveStoredBackup_Call {
68+ _c.Call.Run(func(args mock.Arguments) {
69+ var arg0 domain.BackupRunRecord
70+ if args[0] != nil {
71+ arg0 = args[0].(domain.BackupRunRecord)
72+ }
73+ run(
74+ arg0,
75+ )
76+ })
77+ return _c
78+}
79+
80+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) Return() *MockBackupMetrics_ObserveStoredBackup_Call {
81+ _c.Call.Return()
82+ return _c
83+}
84+
85+func (_c *MockBackupMetrics_ObserveStoredBackup_Call) RunAndReturn(run func(run domain.BackupRunRecord)) *MockBackupMetrics_ObserveStoredBackup_Call {
86+ _c.Run(run)
87+ return _c
88+}
89+
90+// RecordBackupRun provides a mock function for the type MockBackupMetrics
91+func (_mock *MockBackupMetrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecord) {
92+ _mock.Called(ctx, run)
93+ return
94+}
95+
96+// MockBackupMetrics_RecordBackupRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordBackupRun'
97+type MockBackupMetrics_RecordBackupRun_Call struct {
98+ *mock.Call
99+}
100+
101+// RecordBackupRun is a helper method to define mock.On call
102+// - ctx context.Context
103+// - run domain.BackupRunRecord
104+func (_e *MockBackupMetrics_Expecter) RecordBackupRun(ctx any, run any) *MockBackupMetrics_RecordBackupRun_Call {
105+ return &MockBackupMetrics_RecordBackupRun_Call{Call: _e.mock.On("RecordBackupRun", ctx, run)}
106+}
107+
108+func (_c *MockBackupMetrics_RecordBackupRun_Call) Run(run func(ctx context.Context, run domain.BackupRunRecord)) *MockBackupMetrics_RecordBackupRun_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 context.Context
111+ if args[0] != nil {
112+ arg0 = args[0].(context.Context)
113+ }
114+ var arg1 domain.BackupRunRecord
115+ if args[1] != nil {
116+ arg1 = args[1].(domain.BackupRunRecord)
117+ }
118+ run(
119+ arg0,
120+ arg1,
121+ )
122+ })
123+ return _c
124+}
125+
126+func (_c *MockBackupMetrics_RecordBackupRun_Call) Return() *MockBackupMetrics_RecordBackupRun_Call {
127+ _c.Call.Return()
128+ return _c
129+}
130+
131+func (_c *MockBackupMetrics_RecordBackupRun_Call) RunAndReturn(run func(ctx context.Context, run domain.BackupRunRecord)) *MockBackupMetrics_RecordBackupRun_Call {
132+ _c.Run(run)
133+ return _c
134+}
135+
136+// RetainBackupTargets provides a mock function for the type MockBackupMetrics
137+func (_mock *MockBackupMetrics) RetainBackupTargets(kind domain.BackupKind, targets []domain.BackupTargetRef) {
138+ _mock.Called(kind, targets)
139+ return
140+}
141+
142+// MockBackupMetrics_RetainBackupTargets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetainBackupTargets'
143+type MockBackupMetrics_RetainBackupTargets_Call struct {
144+ *mock.Call
145+}
146+
147+// RetainBackupTargets is a helper method to define mock.On call
148+// - kind domain.BackupKind
149+// - targets []domain.BackupTargetRef
150+func (_e *MockBackupMetrics_Expecter) RetainBackupTargets(kind any, targets any) *MockBackupMetrics_RetainBackupTargets_Call {
151+ return &MockBackupMetrics_RetainBackupTargets_Call{Call: _e.mock.On("RetainBackupTargets", kind, targets)}
152+}
153+
154+func (_c *MockBackupMetrics_RetainBackupTargets_Call) Run(run func(kind domain.BackupKind, targets []domain.BackupTargetRef)) *MockBackupMetrics_RetainBackupTargets_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 domain.BackupKind
157+ if args[0] != nil {
158+ arg0 = args[0].(domain.BackupKind)
159+ }
160+ var arg1 []domain.BackupTargetRef
161+ if args[1] != nil {
162+ arg1 = args[1].([]domain.BackupTargetRef)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *MockBackupMetrics_RetainBackupTargets_Call) Return() *MockBackupMetrics_RetainBackupTargets_Call {
173+ _c.Call.Return()
174+ return _c
175+}
176+
177+func (_c *MockBackupMetrics_RetainBackupTargets_Call) RunAndReturn(run func(kind domain.BackupKind, targets []domain.BackupTargetRef)) *MockBackupMetrics_RetainBackupTargets_Call {
178+ _c.Run(run)
179+ return _c
180+}
M internal/domain/backup.go +17 -10
@@ -93,18 +93,25 @@ const (
9393 BackupKindVolume BackupKind = "volume"
9494 )
9595
96+// BackupTargetRef identifies one backed-up resource in metrics. Target is
97+// the database name for a dump and the declared volume name for an archive.
98+type BackupTargetRef struct {
99+ Kind BackupKind
100+ App string
101+ Service string
102+ Target string
103+}
104+
96105 // BackupRunRecord is the outcome of one backup, as reported to metrics.
97-// Target is the database name for a dump and the declared volume name for
98-// an archive.
106+// StartedAt is the start of the run: stored backups are named after it, so
107+// a live run and the same backup read back from storage after a restart
108+// report the same time.
99109 type BackupRunRecord struct {
100- Kind BackupKind
101- App string
102- Service string
103- Target string
104- Succeeded bool
105- Duration time.Duration
106- SizeBytes int64
107- FinishedAt time.Time
110+ BackupTargetRef
111+ Succeeded bool
112+ Duration time.Duration
113+ SizeBytes int64
114+ StartedAt time.Time
108115 }
109116
110117 // DatabaseBackupJob represents a logical database backup operation.
@@ -93,18 +93,25 @@ const (
93 BackupKindVolume BackupKind = "volume"93 BackupKindVolume BackupKind = "volume"
94 )94 )
95 95
96+// BackupTargetRef identifies one backed-up resource in metrics. Target is
97+// the database name for a dump and the declared volume name for an archive.
98+type BackupTargetRef struct {
99+ Kind BackupKind
100+ App string
101+ Service string
102+ Target string
103+}
104+
96 // BackupRunRecord is the outcome of one backup, as reported to metrics.105 // BackupRunRecord is the outcome of one backup, as reported to metrics.
97-// Target is the database name for a dump and the declared volume name for106+// StartedAt is the start of the run: stored backups are named after it, so
98-// an archive.107+// a live run and the same backup read back from storage after a restart
108+// report the same time.
99 type BackupRunRecord struct {109 type BackupRunRecord struct {
100- Kind BackupKind110+ BackupTargetRef
101- App string111+ Succeeded bool
102- Service string112+ Duration time.Duration
103- Target string113+ SizeBytes int64
104- Succeeded bool114+ StartedAt time.Time
105- Duration time.Duration
106- SizeBytes int64
107- FinishedAt time.Time
108 }115 }
109 116
110 // DatabaseBackupJob represents a logical database backup operation.117 // DatabaseBackupJob represents a logical database backup operation.
M internal/usecase/backup/metrics_test.go +126 -128
@@ -5,7 +5,6 @@ import (
55 "errors"
66 "io"
77 "strings"
8- "sync"
98 "testing"
109 "time"
1110
@@ -18,160 +17,159 @@ import (
1817 "github.com/bnema/gordon/internal/domain"
1918 )
2019
21-// recordingMetrics is a concurrency-safe out.BackupMetrics fake.
22-type recordingMetrics struct {
23- mu sync.Mutex
24- runs []domain.BackupRunRecord
25- stored []domain.BackupRunRecord
26-}
27-
28-func (r *recordingMetrics) RecordBackupRun(_ context.Context, run domain.BackupRunRecord) {
29- r.mu.Lock()
30- defer r.mu.Unlock()
31- r.runs = append(r.runs, run)
32-}
20+var (
21+ shopData = domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
22+ shopOrders = domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders"}
23+)
3324
34-func (r *recordingMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {
35- r.mu.Lock()
36- defer r.mu.Unlock()
37- r.stored = append(r.stored, run)
25+// captureRuns records every RecordBackupRun call on the mock.
26+func captureRuns(m *outmocks.MockBackupMetrics) *[]domain.BackupRunRecord {
27+ runs := &[]domain.BackupRunRecord{}
28+ m.EXPECT().RecordBackupRun(mock.Anything, mock.Anything).Run(func(_ context.Context, run domain.BackupRunRecord) {
29+ *runs = append(*runs, run)
30+ })
31+ return runs
3832 }
3933
40-var _ out.BackupMetrics = (*recordingMetrics)(nil)
41-
42-func TestVolumeService_RecordsRunMetrics(t *testing.T) {
43- ctx := context.Background()
44- state := outmocks.NewMockAppStateReader(t)
45- state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
34+func expectShopVolume(state *outmocks.MockAppStateReader) {
4635 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
4736 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
4837 "api": volumeSpec("api", "data", "/data"),
4938 }, "ctr-api"), true, nil).Once()
50-
51- metrics := &recordingMetrics{}
52- svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
53-
54- require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
55- require.Len(t, metrics.runs, 1)
56- run := metrics.runs[0]
57- assert.Equal(t, domain.BackupKindVolume, run.Kind)
58- assert.Equal(t, "shop", run.App)
59- assert.Equal(t, "api", run.Service)
60- assert.Equal(t, "data", run.Target)
61- assert.True(t, run.Succeeded)
62- assert.Equal(t, int64(len("archive")), run.SizeBytes)
63- assert.False(t, run.FinishedAt.IsZero())
6439 }
6540
66-func TestVolumeService_RecordsFailedRun(t *testing.T) {
67- ctx := context.Background()
68- state := outmocks.NewMockAppStateReader(t)
69- state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
70- state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
71- "api": volumeSpec("api", "data", "/data"),
72- }, "ctr-api"), true, nil).Once()
73-
74- metrics := &recordingMetrics{}
75- exporter := &fakeVolumeArchiveExporter{err: errors.New("helper failed")}
76- svc := volumeTestService(t, state, exporter, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
77-
78- _, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
79- require.Error(t, err)
80- require.Len(t, metrics.runs, 1)
81- assert.False(t, metrics.runs[0].Succeeded)
82- assert.Equal(t, "data", metrics.runs[0].Target)
41+// TestVolumeService_RecordsRunMetrics covers the success and failure of a
42+// volume run: the record carries the declared target and the run's start,
43+// which is also the time stored archives are named after.
44+func TestVolumeService_RecordsRunMetrics(t *testing.T) {
45+ for _, tc := range []struct {
46+ name string
47+ export error
48+ success bool
49+ }{
50+ {"success", nil, true},
51+ {"export failure", errors.New("helper failed"), false},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ state := outmocks.NewMockAppStateReader(t)
55+ expectShopVolume(state)
56+ metrics := outmocks.NewMockBackupMetrics(t)
57+ runs := captureRuns(metrics)
58+ storage := &fakeVolumeBackupStorage{}
59+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: tc.export}, storage, true).WithMetrics(metrics)
60+
61+ jobs, _ := svc.RunVolumeBackups(context.Background(), "shop", "api", "data")
62+ require.Len(t, jobs, 1)
63+ require.Len(t, *runs, 1)
64+ run := (*runs)[0]
65+ assert.Equal(t, shopData, run.BackupTargetRef)
66+ assert.Equal(t, tc.success, run.Succeeded)
67+ assert.Equal(t, jobs[0].StartedAt, run.StartedAt)
68+ assert.GreaterOrEqual(t, run.Duration, time.Duration(0))
69+ if tc.success {
70+ assert.Equal(t, int64(len("archive")), run.SizeBytes)
71+ assert.Equal(t, jobs[0].StartedAt, storage.stored[0].StartedAt, "the archive is named after the recorded start")
72+ }
73+ })
74+ }
8375 }
8476
85-func TestVolumeService_SeedMetricsFromStoredArchives(t *testing.T) {
86- ctx := context.Background()
77+// TestVolumeService_SeedMetrics proves seeding forgets undeclared targets,
78+// restores the declared target's newest archive with its declared service,
79+// and ignores archives of targets that are no longer declared.
80+func TestVolumeService_SeedMetrics(t *testing.T) {
81+ state := outmocks.NewMockAppStateReader(t)
82+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
83+ expectShopVolume(state)
8784 started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
8885 storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{
89- {App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted, StartedAt: started, SizeBytes: 42},
86+ {App: "shop", VolumeName: "data", StartedAt: started, SizeBytes: 42},
87+ {App: "gone", VolumeName: "old", StartedAt: started, SizeBytes: 1},
9088 }}
91- metrics := &recordingMetrics{}
92- svc := volumeTestService(t, outmocks.NewMockAppStateReader(t), &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
93-
94- require.NoError(t, svc.SeedMetrics(ctx))
95- require.Len(t, metrics.stored, 1)
96- assert.Equal(t, domain.BackupRunRecord{
97- Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data",
98- Succeeded: true, SizeBytes: 42, FinishedAt: started,
99- }, metrics.stored[0])
100- assert.Empty(t, metrics.runs)
89+ metrics := outmocks.NewMockBackupMetrics(t)
90+ metrics.EXPECT().RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{shopData}).Once()
91+ metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
92+ BackupTargetRef: shopData, Succeeded: true, SizeBytes: 42, StartedAt: started,
93+ }).Once()
94+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
95+
96+ require.NoError(t, svc.SeedMetrics(context.Background()))
10197 }
10298
103-func TestService_RecordsRunMetrics(t *testing.T) {
104- ctx := context.Background()
105- state := outmocks.NewMockAppStateReader(t)
99+func expectShopDatabase(state *outmocks.MockAppStateReader) {
106100 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
107101 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
108102 "api": appSpecWithDatabase("api", "orders", "daily"),
109103 }, "ctr-api"), true, nil).Once()
110-
111- runtime, storage, svc := backupTestService(t, state)
112- metrics := &recordingMetrics{}
113- svc.WithMetrics(metrics)
114- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
115- Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Once()
116- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
117- return strings.HasPrefix(cmd[2], "rm -f ")
118- })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
119-
120- _, err := svc.RunBackup(ctx, "shop", "api", "orders")
121- require.Error(t, err)
122- require.Len(t, metrics.runs, 1)
123- assert.Equal(t, domain.BackupRunRecord{
124- Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
125- Succeeded: false, Duration: metrics.runs[0].Duration, FinishedAt: metrics.runs[0].FinishedAt,
126- }, metrics.runs[0])
127- _ = storage
128104 }
129105
130-func TestService_RecordsSuccessfulRun(t *testing.T) {
131- ctx := context.Background()
132- state := outmocks.NewMockAppStateReader(t)
133- state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
134- state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
135- "api": appSpecWithDatabase("api", "orders", "daily"),
136- }, "ctr-api"), true, nil).Once()
137-
138- runtime, storage, svc := backupTestService(t, state)
139- metrics := &recordingMetrics{}
140- svc.WithMetrics(metrics)
141- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
142- runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
143- Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
144- storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
145- RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
146- _, err := io.Copy(io.Discard, data)
147- return "/b/file.bak", err
148- }).Once()
149-
150- _, err := svc.RunBackup(ctx, "shop", "api", "orders")
151- require.NoError(t, err)
152- require.Len(t, metrics.runs, 1)
153- assert.True(t, metrics.runs[0].Succeeded)
154- assert.Equal(t, int64(len("dump")), metrics.runs[0].SizeBytes)
106+// TestService_RecordsRunMetrics covers a failed and a successful dump. The
107+// recorded start is the timestamp the dump is stored under.
108+func TestService_RecordsRunMetrics(t *testing.T) {
109+ t.Run("pg_dump failure", func(t *testing.T) {
110+ state := outmocks.NewMockAppStateReader(t)
111+ expectShopDatabase(state)
112+ runtime, _, svc := backupTestService(t, state)
113+ metrics := outmocks.NewMockBackupMetrics(t)
114+ runs := captureRuns(metrics)
115+ svc.WithMetrics(metrics)
116+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
117+ Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Twice()
118+
119+ _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
120+ require.Error(t, err)
121+ require.Len(t, *runs, 1)
122+ assert.Equal(t, shopOrders, (*runs)[0].BackupTargetRef)
123+ assert.False(t, (*runs)[0].Succeeded)
124+ assert.False(t, (*runs)[0].StartedAt.IsZero())
125+ })
126+
127+ t.Run("success", func(t *testing.T) {
128+ state := outmocks.NewMockAppStateReader(t)
129+ expectShopDatabase(state)
130+ runtime, storage, svc := backupTestService(t, state)
131+ metrics := outmocks.NewMockBackupMetrics(t)
132+ runs := captureRuns(metrics)
133+ svc.WithMetrics(metrics)
134+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
135+ runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
136+ Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
137+ var storedAt time.Time
138+ storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
139+ RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, at time.Time, data io.Reader) (string, error) {
140+ storedAt = at
141+ _, err := io.Copy(io.Discard, data)
142+ return "/b/file.bak", err
143+ }).Once()
144+
145+ _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
146+ require.NoError(t, err)
147+ require.Len(t, *runs, 1)
148+ assert.True(t, (*runs)[0].Succeeded)
149+ assert.Equal(t, int64(len("dump")), (*runs)[0].SizeBytes)
150+ assert.Equal(t, storedAt, (*runs)[0].StartedAt, "the dump is stored under the recorded start")
151+ })
155152 }
156153
157-func TestService_SeedMetricsFromStoredDumps(t *testing.T) {
158- ctx := context.Background()
154+// TestService_SeedMetrics proves database seeding forgets undeclared
155+// targets and only restores dumps of declared ones.
156+func TestService_SeedMetrics(t *testing.T) {
159157 state := outmocks.NewMockAppStateReader(t)
160- state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
158+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Twice()
159+ expectShopDatabase(state)
161160 started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)
162161
163162 _, storage, svc := backupTestService(t, state)
164- metrics := &recordingMetrics{}
163+ metrics := outmocks.NewMockBackupMetrics(t)
165164 svc.WithMetrics(metrics)
166- storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{
167- App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted,
168- StartedAt: started, SizeBytes: 7,
169- }}, nil).Once()
170-
171- require.NoError(t, svc.SeedMetrics(ctx))
172- require.Len(t, metrics.stored, 1)
173- assert.Equal(t, domain.BackupRunRecord{
174- Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
175- Succeeded: true, SizeBytes: 7, FinishedAt: started,
176- }, metrics.stored[0])
165+ storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{
166+ {App: "shop", Service: "api", DBName: "orders", StartedAt: started, SizeBytes: 7},
167+ {App: "shop", Service: "api", DBName: "dropped", StartedAt: started, SizeBytes: 1},
168+ }, nil).Once()
169+ metrics.EXPECT().RetainBackupTargets(domain.BackupKindDatabase, []domain.BackupTargetRef{shopOrders}).Once()
170+ metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
171+ BackupTargetRef: shopOrders, Succeeded: true, SizeBytes: 7, StartedAt: started,
172+ }).Once()
173+
174+ require.NoError(t, svc.SeedMetrics(context.Background()))
177175 }
@@ -5,7 +5,6 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "strings"7 "strings"
8- "sync"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
@@ -18,160 +17,159 @@ import (
18 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
19 )18 )
20 19
21-// recordingMetrics is a concurrency-safe out.BackupMetrics fake.20+var (
22-type recordingMetrics struct {21+ shopData = domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
23- mu sync.Mutex22+ shopOrders = domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders"}
24- runs []domain.BackupRunRecord23+)
25- stored []domain.BackupRunRecord
26-}
27-
28-func (r *recordingMetrics) RecordBackupRun(_ context.Context, run domain.BackupRunRecord) {
29- r.mu.Lock()
30- defer r.mu.Unlock()
31- r.runs = append(r.runs, run)
32-}
33 24
34-func (r *recordingMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {25+// captureRuns records every RecordBackupRun call on the mock.
35- r.mu.Lock()26+func captureRuns(m *outmocks.MockBackupMetrics) *[]domain.BackupRunRecord {
36- defer r.mu.Unlock()27+ runs := &[]domain.BackupRunRecord{}
37- r.stored = append(r.stored, run)28+ m.EXPECT().RecordBackupRun(mock.Anything, mock.Anything).Run(func(_ context.Context, run domain.BackupRunRecord) {
29+ *runs = append(*runs, run)
30+ })
31+ return runs
38 }32 }
39 33
40-var _ out.BackupMetrics = (*recordingMetrics)(nil)34+func expectShopVolume(state *outmocks.MockAppStateReader) {
41-
42-func TestVolumeService_RecordsRunMetrics(t *testing.T) {
43- ctx := context.Background()
44- state := outmocks.NewMockAppStateReader(t)
45- state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
46 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()35 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
47 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{36 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
48 "api": volumeSpec("api", "data", "/data"),37 "api": volumeSpec("api", "data", "/data"),
49 }, "ctr-api"), true, nil).Once()38 }, "ctr-api"), true, nil).Once()
50-
51- metrics := &recordingMetrics{}
52- svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
53-
54- require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
55- require.Len(t, metrics.runs, 1)
56- run := metrics.runs[0]
57- assert.Equal(t, domain.BackupKindVolume, run.Kind)
58- assert.Equal(t, "shop", run.App)
59- assert.Equal(t, "api", run.Service)
60- assert.Equal(t, "data", run.Target)
61- assert.True(t, run.Succeeded)
62- assert.Equal(t, int64(len("archive")), run.SizeBytes)
63- assert.False(t, run.FinishedAt.IsZero())
64 }39 }
65 40
66-func TestVolumeService_RecordsFailedRun(t *testing.T) {41+// TestVolumeService_RecordsRunMetrics covers the success and failure of a
67- ctx := context.Background()42+// volume run: the record carries the declared target and the run's start,
68- state := outmocks.NewMockAppStateReader(t)43+// which is also the time stored archives are named after.
69- state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()44+func TestVolumeService_RecordsRunMetrics(t *testing.T) {
70- state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{45+ for _, tc := range []struct {
71- "api": volumeSpec("api", "data", "/data"),46+ name string
72- }, "ctr-api"), true, nil).Once()47+ export error
73-48+ success bool
74- metrics := &recordingMetrics{}49+ }{
75- exporter := &fakeVolumeArchiveExporter{err: errors.New("helper failed")}50+ {"success", nil, true},
76- svc := volumeTestService(t, state, exporter, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)51+ {"export failure", errors.New("helper failed"), false},
77-52+ } {
78- _, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")53+ t.Run(tc.name, func(t *testing.T) {
79- require.Error(t, err)54+ state := outmocks.NewMockAppStateReader(t)
80- require.Len(t, metrics.runs, 1)55+ expectShopVolume(state)
81- assert.False(t, metrics.runs[0].Succeeded)56+ metrics := outmocks.NewMockBackupMetrics(t)
82- assert.Equal(t, "data", metrics.runs[0].Target)57+ runs := captureRuns(metrics)
58+ storage := &fakeVolumeBackupStorage{}
59+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: tc.export}, storage, true).WithMetrics(metrics)
60+
61+ jobs, _ := svc.RunVolumeBackups(context.Background(), "shop", "api", "data")
62+ require.Len(t, jobs, 1)
63+ require.Len(t, *runs, 1)
64+ run := (*runs)[0]
65+ assert.Equal(t, shopData, run.BackupTargetRef)
66+ assert.Equal(t, tc.success, run.Succeeded)
67+ assert.Equal(t, jobs[0].StartedAt, run.StartedAt)
68+ assert.GreaterOrEqual(t, run.Duration, time.Duration(0))
69+ if tc.success {
70+ assert.Equal(t, int64(len("archive")), run.SizeBytes)
71+ assert.Equal(t, jobs[0].StartedAt, storage.stored[0].StartedAt, "the archive is named after the recorded start")
72+ }
73+ })
74+ }
83 }75 }
84 76
85-func TestVolumeService_SeedMetricsFromStoredArchives(t *testing.T) {77+// TestVolumeService_SeedMetrics proves seeding forgets undeclared targets,
86- ctx := context.Background()78+// restores the declared target's newest archive with its declared service,
79+// and ignores archives of targets that are no longer declared.
80+func TestVolumeService_SeedMetrics(t *testing.T) {
81+ state := outmocks.NewMockAppStateReader(t)
82+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
83+ expectShopVolume(state)
87 started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)84 started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
88 storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{85 storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{
89- {App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted, StartedAt: started, SizeBytes: 42},86+ {App: "shop", VolumeName: "data", StartedAt: started, SizeBytes: 42},
87+ {App: "gone", VolumeName: "old", StartedAt: started, SizeBytes: 1},
90 }}88 }}
91- metrics := &recordingMetrics{}89+ metrics := outmocks.NewMockBackupMetrics(t)
92- svc := volumeTestService(t, outmocks.NewMockAppStateReader(t), &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)90+ metrics.EXPECT().RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{shopData}).Once()
93-91+ metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
94- require.NoError(t, svc.SeedMetrics(ctx))92+ BackupTargetRef: shopData, Succeeded: true, SizeBytes: 42, StartedAt: started,
95- require.Len(t, metrics.stored, 1)93+ }).Once()
96- assert.Equal(t, domain.BackupRunRecord{94+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
97- Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data",95+
98- Succeeded: true, SizeBytes: 42, FinishedAt: started,96+ require.NoError(t, svc.SeedMetrics(context.Background()))
99- }, metrics.stored[0])
100- assert.Empty(t, metrics.runs)
101 }97 }
102 98
103-func TestService_RecordsRunMetrics(t *testing.T) {99+func expectShopDatabase(state *outmocks.MockAppStateReader) {
104- ctx := context.Background()
105- state := outmocks.NewMockAppStateReader(t)
106 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()100 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
107 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{101 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
108 "api": appSpecWithDatabase("api", "orders", "daily"),102 "api": appSpecWithDatabase("api", "orders", "daily"),
109 }, "ctr-api"), true, nil).Once()103 }, "ctr-api"), true, nil).Once()
110-
111- runtime, storage, svc := backupTestService(t, state)
112- metrics := &recordingMetrics{}
113- svc.WithMetrics(metrics)
114- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
115- Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Once()
116- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
117- return strings.HasPrefix(cmd[2], "rm -f ")
118- })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
119-
120- _, err := svc.RunBackup(ctx, "shop", "api", "orders")
121- require.Error(t, err)
122- require.Len(t, metrics.runs, 1)
123- assert.Equal(t, domain.BackupRunRecord{
124- Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
125- Succeeded: false, Duration: metrics.runs[0].Duration, FinishedAt: metrics.runs[0].FinishedAt,
126- }, metrics.runs[0])
127- _ = storage
128 }104 }
129 105
130-func TestService_RecordsSuccessfulRun(t *testing.T) {106+// TestService_RecordsRunMetrics covers a failed and a successful dump. The
131- ctx := context.Background()107+// recorded start is the timestamp the dump is stored under.
132- state := outmocks.NewMockAppStateReader(t)108+func TestService_RecordsRunMetrics(t *testing.T) {
133- state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()109+ t.Run("pg_dump failure", func(t *testing.T) {
134- state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{110+ state := outmocks.NewMockAppStateReader(t)
135- "api": appSpecWithDatabase("api", "orders", "daily"),111+ expectShopDatabase(state)
136- }, "ctr-api"), true, nil).Once()112+ runtime, _, svc := backupTestService(t, state)
137-113+ metrics := outmocks.NewMockBackupMetrics(t)
138- runtime, storage, svc := backupTestService(t, state)114+ runs := captureRuns(metrics)
139- metrics := &recordingMetrics{}115+ svc.WithMetrics(metrics)
140- svc.WithMetrics(metrics)116+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
141- runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()117+ Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Twice()
142- runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).118+
143- Return(io.NopCloser(strings.NewReader("dump")), nil).Once()119+ _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
144- storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).120+ require.Error(t, err)
145- RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {121+ require.Len(t, *runs, 1)
146- _, err := io.Copy(io.Discard, data)122+ assert.Equal(t, shopOrders, (*runs)[0].BackupTargetRef)
147- return "/b/file.bak", err123+ assert.False(t, (*runs)[0].Succeeded)
148- }).Once()124+ assert.False(t, (*runs)[0].StartedAt.IsZero())
149-125+ })
150- _, err := svc.RunBackup(ctx, "shop", "api", "orders")126+
151- require.NoError(t, err)127+ t.Run("success", func(t *testing.T) {
152- require.Len(t, metrics.runs, 1)128+ state := outmocks.NewMockAppStateReader(t)
153- assert.True(t, metrics.runs[0].Succeeded)129+ expectShopDatabase(state)
154- assert.Equal(t, int64(len("dump")), metrics.runs[0].SizeBytes)130+ runtime, storage, svc := backupTestService(t, state)
131+ metrics := outmocks.NewMockBackupMetrics(t)
132+ runs := captureRuns(metrics)
133+ svc.WithMetrics(metrics)
134+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
135+ runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
136+ Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
137+ var storedAt time.Time
138+ storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
139+ RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, at time.Time, data io.Reader) (string, error) {
140+ storedAt = at
141+ _, err := io.Copy(io.Discard, data)
142+ return "/b/file.bak", err
143+ }).Once()
144+
145+ _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
146+ require.NoError(t, err)
147+ require.Len(t, *runs, 1)
148+ assert.True(t, (*runs)[0].Succeeded)
149+ assert.Equal(t, int64(len("dump")), (*runs)[0].SizeBytes)
150+ assert.Equal(t, storedAt, (*runs)[0].StartedAt, "the dump is stored under the recorded start")
151+ })
155 }152 }
156 153
157-func TestService_SeedMetricsFromStoredDumps(t *testing.T) {154+// TestService_SeedMetrics proves database seeding forgets undeclared
158- ctx := context.Background()155+// targets and only restores dumps of declared ones.
156+func TestService_SeedMetrics(t *testing.T) {
159 state := outmocks.NewMockAppStateReader(t)157 state := outmocks.NewMockAppStateReader(t)
160- state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()158+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Twice()
159+ expectShopDatabase(state)
161 started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)160 started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)
162 161
163 _, storage, svc := backupTestService(t, state)162 _, storage, svc := backupTestService(t, state)
164- metrics := &recordingMetrics{}163+ metrics := outmocks.NewMockBackupMetrics(t)
165 svc.WithMetrics(metrics)164 svc.WithMetrics(metrics)
166- storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{165+ storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{
167- App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted,166+ {App: "shop", Service: "api", DBName: "orders", StartedAt: started, SizeBytes: 7},
168- StartedAt: started, SizeBytes: 7,167+ {App: "shop", Service: "api", DBName: "dropped", StartedAt: started, SizeBytes: 1},
169- }}, nil).Once()168+ }, nil).Once()
170-169+ metrics.EXPECT().RetainBackupTargets(domain.BackupKindDatabase, []domain.BackupTargetRef{shopOrders}).Once()
171- require.NoError(t, svc.SeedMetrics(ctx))170+ metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
172- require.Len(t, metrics.stored, 1)171+ BackupTargetRef: shopOrders, Succeeded: true, SizeBytes: 7, StartedAt: started,
173- assert.Equal(t, domain.BackupRunRecord{172+ }).Once()
174- Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",173+
175- Succeeded: true, SizeBytes: 7, FinishedAt: started,174+ require.NoError(t, svc.SeedMetrics(context.Background()))
176- }, metrics.stored[0])
177 }175 }
M internal/usecase/backup/service.go +34 -12
@@ -60,25 +60,47 @@ func (s *Service) WithMetrics(metrics out.BackupMetrics) *Service {
6060 return s
6161 }
6262
63-// SeedMetrics reports the newest stored dump of every target as its last
64-// success, so the metric survives a restart. It counts no run.
63+// SeedMetrics reconciles backup metrics with the declared database
64+// targets: it forgets targets that are no longer declared and reports the
65+// newest stored dump of each declared one as its last success, so the
66+// metric survives a restart. It counts no run.
6567 func (s *Service) SeedMetrics(ctx context.Context) error {
6668 if s.metrics == nil {
6769 return nil
6870 }
71+ targets, err := s.allTargets(ctx)
72+ if err != nil {
73+ return fmt.Errorf("seed backup metrics: %w", err)
74+ }
75+ refs := make([]domain.BackupTargetRef, 0, len(targets))
76+ declared := make(map[domain.BackupTargetRef]struct{}, len(targets))
77+ for _, target := range targets {
78+ ref := databaseTargetRef(target)
79+ refs = append(refs, ref)
80+ declared[ref] = struct{}{}
81+ }
82+ s.metrics.RetainBackupTargets(domain.BackupKindDatabase, refs)
83+
6984 jobs, err := s.ListBackups(ctx, "")
7085 if err != nil {
7186 return fmt.Errorf("seed backup metrics: %w", err)
7287 }
7388 for _, job := range jobs {
89+ ref := domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName}
90+ if _, ok := declared[ref]; !ok {
91+ continue
92+ }
7493 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
75- Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName,
76- Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
94+ BackupTargetRef: ref, Succeeded: true, SizeBytes: job.SizeBytes, StartedAt: job.StartedAt,
7795 })
7896 }
7997 return nil
8098 }
8199
100+func databaseTargetRef(target domain.DatabaseTarget) domain.BackupTargetRef {
101+ return domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database}
102+}
103+
82104 // Targets returns every declared database target of one app, ordered by
83105 // service then database.
84106 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {
@@ -238,15 +260,16 @@ func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
238260 }
239261
240262 // runTarget runs one declared database backup through the service
241-// container that hosts it.
242-// runTarget dumps one target and records the outcome in metrics.
263+// container that hosts it and records the outcome in metrics.
243264 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
244- started := time.Now()
245- result, err := s.dumpTarget(ctx, target, schedule)
265+ started := time.Now().UTC()
266+ result, err := s.dumpTarget(ctx, target, schedule, started)
246267 if s.metrics != nil {
247268 run := domain.BackupRunRecord{
248- Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database,
249- Succeeded: err == nil, Duration: time.Since(started), FinishedAt: time.Now().UTC(),
269+ BackupTargetRef: databaseTargetRef(target),
270+ Succeeded: err == nil,
271+ Duration: time.Since(started),
272+ StartedAt: started,
250273 }
251274 if result != nil {
252275 run.SizeBytes = result.Job.SizeBytes
@@ -256,11 +279,10 @@ func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, s
256279 return result, err
257280 }
258281
259-func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
282+func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule, started time.Time) (*domain.BackupResult, error) {
260283 if target.ContainerID == "" {
261284 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)
262285 }
263- started := time.Now().UTC()
264286
265287 execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)
266288 defer cancelExec()
@@ -60,25 +60,47 @@ func (s *Service) WithMetrics(metrics out.BackupMetrics) *Service {
60 return s60 return s
61 }61 }
62 62
63-// SeedMetrics reports the newest stored dump of every target as its last63+// SeedMetrics reconciles backup metrics with the declared database
64-// success, so the metric survives a restart. It counts no run.64+// targets: it forgets targets that are no longer declared and reports the
65+// newest stored dump of each declared one as its last success, so the
66+// metric survives a restart. It counts no run.
65 func (s *Service) SeedMetrics(ctx context.Context) error {67 func (s *Service) SeedMetrics(ctx context.Context) error {
66 if s.metrics == nil {68 if s.metrics == nil {
67 return nil69 return nil
68 }70 }
71+ targets, err := s.allTargets(ctx)
72+ if err != nil {
73+ return fmt.Errorf("seed backup metrics: %w", err)
74+ }
75+ refs := make([]domain.BackupTargetRef, 0, len(targets))
76+ declared := make(map[domain.BackupTargetRef]struct{}, len(targets))
77+ for _, target := range targets {
78+ ref := databaseTargetRef(target)
79+ refs = append(refs, ref)
80+ declared[ref] = struct{}{}
81+ }
82+ s.metrics.RetainBackupTargets(domain.BackupKindDatabase, refs)
83+
69 jobs, err := s.ListBackups(ctx, "")84 jobs, err := s.ListBackups(ctx, "")
70 if err != nil {85 if err != nil {
71 return fmt.Errorf("seed backup metrics: %w", err)86 return fmt.Errorf("seed backup metrics: %w", err)
72 }87 }
73 for _, job := range jobs {88 for _, job := range jobs {
89+ ref := domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName}
90+ if _, ok := declared[ref]; !ok {
91+ continue
92+ }
74 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{93 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
75- Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName,94+ BackupTargetRef: ref, Succeeded: true, SizeBytes: job.SizeBytes, StartedAt: job.StartedAt,
76- Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
77 })95 })
78 }96 }
79 return nil97 return nil
80 }98 }
81 99
100+func databaseTargetRef(target domain.DatabaseTarget) domain.BackupTargetRef {
101+ return domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database}
102+}
103+
82 // Targets returns every declared database target of one app, ordered by104 // Targets returns every declared database target of one app, ordered by
83 // service then database.105 // service then database.
84 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {106 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {
@@ -238,15 +260,16 @@ func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
238 }260 }
239 261
240 // runTarget runs one declared database backup through the service262 // runTarget runs one declared database backup through the service
241-// container that hosts it.263+// container that hosts it and records the outcome in metrics.
242-// runTarget dumps one target and records the outcome in metrics.
243 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {264 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
244- started := time.Now()265+ started := time.Now().UTC()
245- result, err := s.dumpTarget(ctx, target, schedule)266+ result, err := s.dumpTarget(ctx, target, schedule, started)
246 if s.metrics != nil {267 if s.metrics != nil {
247 run := domain.BackupRunRecord{268 run := domain.BackupRunRecord{
248- Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database,269+ BackupTargetRef: databaseTargetRef(target),
249- Succeeded: err == nil, Duration: time.Since(started), FinishedAt: time.Now().UTC(),270+ Succeeded: err == nil,
271+ Duration: time.Since(started),
272+ StartedAt: started,
250 }273 }
251 if result != nil {274 if result != nil {
252 run.SizeBytes = result.Job.SizeBytes275 run.SizeBytes = result.Job.SizeBytes
@@ -256,11 +279,10 @@ func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, s
256 return result, err279 return result, err
257 }280 }
258 281
259-func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {282+func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule, started time.Time) (*domain.BackupResult, error) {
260 if target.ContainerID == "" {283 if target.ContainerID == "" {
261 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)284 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)
262 }285 }
263- started := time.Now().UTC()
264 286
265 execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)287 execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)
266 defer cancelExec()288 defer cancelExec()
M internal/usecase/backup/volume_service.go +33 -9
@@ -56,25 +56,47 @@ func (s *VolumeService) WithMetrics(metrics out.BackupMetrics) *VolumeService {
5656 return s
5757 }
5858
59-// SeedMetrics reports the newest stored archive of every target as its
60-// last success, so the metric survives a restart. It counts no run.
59+// SeedMetrics reconciles backup metrics with the declared volume targets:
60+// it forgets targets that are no longer declared and reports the newest
61+// stored archive of each declared one as its last success, so the metric
62+// survives a restart. It counts no run.
6163 func (s *VolumeService) SeedMetrics(ctx context.Context) error {
6264 if s.metrics == nil {
6365 return nil
6466 }
65- jobs, err := s.storage.ListVolumeArchives(ctx, "")
67+ targets, err := s.allVolumeTargets(ctx)
6668 if err != nil {
6769 return fmt.Errorf("seed volume backup metrics: %w", err)
6870 }
69- for _, job := range jobs {
71+ refs := make([]domain.BackupTargetRef, 0, len(targets))
72+ byVolume := make(map[string]domain.BackupTargetRef, len(targets))
73+ for _, target := range targets {
74+ ref := volumeTargetRef(target)
75+ refs = append(refs, ref)
76+ byVolume[target.App+"/"+target.VolumeName] = ref
77+ }
78+ s.metrics.RetainBackupTargets(domain.BackupKindVolume, refs)
79+
80+ archives, err := s.storage.LatestVolumeArchives(ctx)
81+ if err != nil {
82+ return fmt.Errorf("seed volume backup metrics: %w", err)
83+ }
84+ for _, archive := range archives {
85+ ref, declared := byVolume[archive.App+"/"+archive.VolumeName]
86+ if !declared {
87+ continue
88+ }
7089 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
71- Kind: domain.BackupKindVolume, App: job.App, Service: job.Service, Target: job.VolumeName,
72- Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
90+ BackupTargetRef: ref, Succeeded: true, SizeBytes: archive.SizeBytes, StartedAt: archive.StartedAt,
7391 })
7492 }
7593 return nil
7694 }
7795
96+func volumeTargetRef(target domain.VolumeBackupTarget) domain.BackupTargetRef {
97+ return domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName}
98+}
99+
78100 // VolumeTargets returns every declared volume target of one app.
79101 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {
80102 if s.state == nil {
@@ -246,9 +268,11 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
246268 job := s.archiveVolume(ctx, target)
247269 if s.metrics != nil {
248270 s.metrics.RecordBackupRun(ctx, domain.BackupRunRecord{
249- Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName,
250- Succeeded: job.Status == domain.BackupStatusCompleted, Duration: job.CompletedAt.Sub(job.StartedAt),
251- SizeBytes: job.SizeBytes, FinishedAt: job.CompletedAt,
271+ BackupTargetRef: volumeTargetRef(target),
272+ Succeeded: job.Status == domain.BackupStatusCompleted,
273+ Duration: job.CompletedAt.Sub(job.StartedAt),
274+ SizeBytes: job.SizeBytes,
275+ StartedAt: job.StartedAt,
252276 })
253277 }
254278 return job
@@ -56,25 +56,47 @@ func (s *VolumeService) WithMetrics(metrics out.BackupMetrics) *VolumeService {
56 return s56 return s
57 }57 }
58 58
59-// SeedMetrics reports the newest stored archive of every target as its59+// SeedMetrics reconciles backup metrics with the declared volume targets:
60-// last success, so the metric survives a restart. It counts no run.60+// it forgets targets that are no longer declared and reports the newest
61+// stored archive of each declared one as its last success, so the metric
62+// survives a restart. It counts no run.
61 func (s *VolumeService) SeedMetrics(ctx context.Context) error {63 func (s *VolumeService) SeedMetrics(ctx context.Context) error {
62 if s.metrics == nil {64 if s.metrics == nil {
63 return nil65 return nil
64 }66 }
65- jobs, err := s.storage.ListVolumeArchives(ctx, "")67+ targets, err := s.allVolumeTargets(ctx)
66 if err != nil {68 if err != nil {
67 return fmt.Errorf("seed volume backup metrics: %w", err)69 return fmt.Errorf("seed volume backup metrics: %w", err)
68 }70 }
69- for _, job := range jobs {71+ refs := make([]domain.BackupTargetRef, 0, len(targets))
72+ byVolume := make(map[string]domain.BackupTargetRef, len(targets))
73+ for _, target := range targets {
74+ ref := volumeTargetRef(target)
75+ refs = append(refs, ref)
76+ byVolume[target.App+"/"+target.VolumeName] = ref
77+ }
78+ s.metrics.RetainBackupTargets(domain.BackupKindVolume, refs)
79+
80+ archives, err := s.storage.LatestVolumeArchives(ctx)
81+ if err != nil {
82+ return fmt.Errorf("seed volume backup metrics: %w", err)
83+ }
84+ for _, archive := range archives {
85+ ref, declared := byVolume[archive.App+"/"+archive.VolumeName]
86+ if !declared {
87+ continue
88+ }
70 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{89 s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
71- Kind: domain.BackupKindVolume, App: job.App, Service: job.Service, Target: job.VolumeName,90+ BackupTargetRef: ref, Succeeded: true, SizeBytes: archive.SizeBytes, StartedAt: archive.StartedAt,
72- Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
73 })91 })
74 }92 }
75 return nil93 return nil
76 }94 }
77 95
96+func volumeTargetRef(target domain.VolumeBackupTarget) domain.BackupTargetRef {
97+ return domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName}
98+}
99+
78 // VolumeTargets returns every declared volume target of one app.100 // VolumeTargets returns every declared volume target of one app.
79 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {101 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {
80 if s.state == nil {102 if s.state == nil {
@@ -246,9 +268,11 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
246 job := s.archiveVolume(ctx, target)268 job := s.archiveVolume(ctx, target)
247 if s.metrics != nil {269 if s.metrics != nil {
248 s.metrics.RecordBackupRun(ctx, domain.BackupRunRecord{270 s.metrics.RecordBackupRun(ctx, domain.BackupRunRecord{
249- Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName,271+ BackupTargetRef: volumeTargetRef(target),
250- Succeeded: job.Status == domain.BackupStatusCompleted, Duration: job.CompletedAt.Sub(job.StartedAt),272+ Succeeded: job.Status == domain.BackupStatusCompleted,
251- SizeBytes: job.SizeBytes, FinishedAt: job.CompletedAt,273+ Duration: job.CompletedAt.Sub(job.StartedAt),
274+ SizeBytes: job.SizeBytes,
275+ StartedAt: job.StartedAt,
252 })276 })
253 }277 }
254 return job278 return job
M internal/usecase/backup/volume_service_test.go +9 -0
@@ -90,6 +90,15 @@ func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([
9090 return f.jobs, nil
9191 }
9292
93+// LatestVolumeArchives returns the configured jobs as-is; the S3 adapter
94+// owns the newest-per-volume selection.
95+func (f *fakeVolumeBackupStorage) LatestVolumeArchives(context.Context) ([]domain.VolumeBackupJob, error) {
96+ if f.listErr != nil {
97+ return nil, f.listErr
98+ }
99+ return f.jobs, nil
100+}
101+
93102 func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
94103
95104 func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
@@ -90,6 +90,15 @@ func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([
90 return f.jobs, nil90 return f.jobs, nil
91 }91 }
92 92
93+// LatestVolumeArchives returns the configured jobs as-is; the S3 adapter
94+// owns the newest-per-volume selection.
95+func (f *fakeVolumeBackupStorage) LatestVolumeArchives(context.Context) ([]domain.VolumeBackupJob, error) {
96+ if f.listErr != nil {
97+ return nil, f.listErr
98+ }
99+ return f.jobs, nil
100+}
101+
93 func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }102 func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
94 103
95 func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {104 func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {

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