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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +551 -204
M .mockery.yaml +1 -0
| @@ -20,6 +20,7 @@ packages: | |||
| 20 | 20 | SecretFileStore: | |
| 21 | 21 | ContainerLogWriter: | |
| 22 | 22 | Metrics: | |
| 23 | + | BackupMetrics: | |
| 23 | 24 | LogExporter: | |
| 24 | 25 | ContainerLogStreamer: | |
| 25 | 26 | 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` | |||
| 166 | 166 | |--------|------|------|-------------| | |
| 167 | 167 | | `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) | | |
| 168 | 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 | 170 | | `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target | | |
| 171 | 171 | | `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run | | |
| 172 | 172 | ||
| 173 | 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 | 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 | 180 | # No successful backup of a target for more than 26 hours | |
| 181 | 181 | time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600 | |
| 182 | 182 | ||
| 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]) | |
| 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 | 189 | ### Event Bus | |
| 188 | 190 | ||
| 189 | 191 | | 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 hours | 180 | # No successful backup of a target for more than 26 hours | ||
| 181 | time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600 | 181 | time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600 | ||
| 182 | 182 | ||||
| 183 | - | # Gordon stopped exporting backup metrics | 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[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 Bus | 189 | ### 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 | |||
| 150 | 150 | ||
| 151 | 151 | // ListVolumeArchives lists completed volume archive backups for a domain. | |
| 152 | 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 | 189 | listPrefix := s.appPrefix(app) | |
| 154 | 190 | jobs := make([]domain.VolumeBackupJob, 0) | |
| 155 | 191 | var token *string | |
| @@ -173,7 +209,6 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string | |||
| 173 | 209 | if obj.Size != nil { | |
| 174 | 210 | job.SizeBytes = *obj.Size | |
| 175 | 211 | } | |
| 176 | - | s.hydrateVolumeBackupJobMetadata(ctx, &job, *obj.Key) | |
| 177 | 212 | jobs = append(jobs, job) | |
| 178 | 213 | } | |
| 179 | 214 | if out.IsTruncated == nil || !*out.IsTruncated { | |
| @@ -181,11 +216,14 @@ func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, app string | |||
| 181 | 216 | } | |
| 182 | 217 | token = out.NextContinuationToken | |
| 183 | 218 | } | |
| 184 | - | ||
| 185 | - | sortVolumeBackupJobs(jobs) | |
| 186 | 219 | 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 | 227 | // DeleteVolumeArchive deletes a volume archive by artifact reference. | |
| 190 | 228 | func (s *VolumeBackupStorage) hydrateVolumeBackupJobMetadata(ctx context.Context, job *domain.VolumeBackupJob, key string) { | |
| 191 | 229 | 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 *string | 191 | 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.Size | 210 | 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.NextContinuationToken | 217 | token = out.NextContinuationToken | ||
| 183 | } | 218 | } | ||
| 184 | - | ||||
| 185 | - | sortVolumeBackupJobs(jobs) | |||
| 186 | return jobs, nil | 219 | 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 { | |||
| 22 | 22 | objects map[string][]byte | |
| 23 | 23 | metadata map[string]map[string]string | |
| 24 | 24 | deleted []string | |
| 25 | + | heads int | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | func newFakeVolumeS3Client() *fakeVolumeS3Client { | |
| @@ -34,6 +35,7 @@ func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjec | |||
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) { | |
| 38 | + | f.heads++ | |
| 37 | 39 | 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 | 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 | 203 | func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) { | |
| 170 | 204 | client := newFakeVolumeS3Client() | |
| 171 | 205 | storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client}) | |
| @@ -22,6 +22,7 @@ type fakeVolumeS3Client struct { | |||||
| 22 | objects map[string][]byte | 22 | objects map[string][]byte | ||
| 23 | metadata map[string]map[string]string | 23 | metadata map[string]map[string]string | ||
| 24 | deleted []string | 24 | 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)]}, nil | 39 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | var _ out.BackupMetrics = (*Metrics)(nil) | |
| 16 | 16 | ||
| 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 { | |
| 26 | 18 | 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), | |
| 31 | 23 | } | |
| 32 | 24 | } | |
| 33 | 25 | ||
| @@ -40,33 +32,44 @@ type backupLastSuccess struct { | |||
| 40 | 32 | // gauges at each collection. | |
| 41 | 33 | type backupState struct { | |
| 42 | 34 | mu sync.Mutex | |
| 43 | - | last map[backupTargetKey]backupLastSuccess | |
| 35 | + | last map[domain.BackupTargetRef]backupLastSuccess | |
| 44 | 36 | nextRun map[domain.BackupKind]func() time.Time | |
| 45 | 37 | } | |
| 46 | 38 | ||
| 47 | 39 | func newBackupState() *backupState { | |
| 48 | 40 | return &backupState{ | |
| 49 | - | last: map[backupTargetKey]backupLastSuccess{}, | |
| 41 | + | last: map[domain.BackupTargetRef]backupLastSuccess{}, | |
| 50 | 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 | 46 | // remember stores a success unless a newer one is already known. | |
| 59 | 47 | func (s *backupState) remember(run domain.BackupRunRecord) { | |
| 60 | - | if !run.Succeeded || run.FinishedAt.IsZero() { | |
| 48 | + | if !run.Succeeded || run.StartedAt.IsZero() { | |
| 61 | 49 | return | |
| 62 | 50 | } | |
| 63 | - | key := keyOf(run) | |
| 51 | + | key := run.BackupTargetRef | |
| 64 | 52 | s.mu.Lock() | |
| 65 | 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 | 55 | 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 | 75 | // registerBackupInstruments creates the backup counters, histogram, and | |
| @@ -84,7 +87,7 @@ func (m *Metrics) registerBackupInstruments() error { | |||
| 84 | 87 | return err | |
| 85 | 88 | } | |
| 86 | 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 | 91 | metric.WithUnit("s")) | |
| 89 | 92 | if err != nil { | |
| 90 | 93 | return err | |
| @@ -105,7 +108,7 @@ func (m *Metrics) registerBackupInstruments() error { | |||
| 105 | 108 | m.backups.mu.Lock() | |
| 106 | 109 | defer m.backups.mu.Unlock() | |
| 107 | 110 | for key, last := range m.backups.last { | |
| 108 | - | attrs := metric.WithAttributes(key.attrs()...) | |
| 111 | + | attrs := metric.WithAttributes(targetAttrs(key)...) | |
| 109 | 112 | o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs) | |
| 110 | 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 | 128 | if run.Succeeded { | |
| 126 | 129 | result = "success" | |
| 127 | 130 | } | |
| 128 | - | attrs := keyOf(run).attrs() | |
| 131 | + | attrs := targetAttrs(run.BackupTargetRef) | |
| 129 | 132 | m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...)) | |
| 130 | 133 | m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...)) | |
| 131 | 134 | m.backups.remember(run) | |
| @@ -136,6 +139,11 @@ func (m *Metrics) ObserveStoredBackup(run domain.BackupRunRecord) { | |||
| 136 | 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 | 147 | // ObserveBackupNextRun exports the next planned run of one backup kind. | |
| 140 | 148 | // next is called at each collection; a zero time is not exported. | |
| 141 | 149 | 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.Mutex | 34 | mu sync.Mutex | ||
| 43 | - | last map[backupTargetKey]backupLastSuccess | 35 | + | last map[domain.BackupTargetRef]backupLastSuccess |
| 44 | nextRun map[domain.BackupKind]func() time.Time | 36 | 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 | return | 49 | 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 | return | 55 | 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, and | 75 | // registerBackupInstruments creates the backup counters, histogram, and | ||
| @@ -84,7 +87,7 @@ func (m *Metrics) registerBackupInstruments() error { | |||||
| 84 | return err | 87 | 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 err | 93 | 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) { | |||
| 61 | 61 | ctx := context.Background() | |
| 62 | 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 | 65 | 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, | |
| 67 | 67 | }) | |
| 68 | 68 | 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), | |
| 71 | 70 | }) | |
| 72 | 71 | ||
| 73 | 72 | data := collect(t, reader) | |
| @@ -86,11 +85,9 @@ func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) { | |||
| 86 | 85 | ||
| 87 | 86 | hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64]) | |
| 88 | 87 | 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) | |
| 94 | 91 | ||
| 95 | 92 | // A failure never moves the last-success timestamp or size. | |
| 96 | 93 | assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())}, | |
| @@ -103,11 +100,14 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) { | |||
| 103 | 100 | m, reader := newTestMetrics(t) | |
| 104 | 101 | newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC) | |
| 105 | 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, 200 | |
| 108 | + | rec.StartedAt, rec.SizeBytes = newer, 200 | |
| 109 | 109 | m.ObserveStoredBackup(rec) | |
| 110 | - | rec.FinishedAt, rec.SizeBytes = older, 100 | |
| 110 | + | rec.StartedAt, rec.SizeBytes = older, 100 | |
| 111 | 111 | m.ObserveStoredBackup(rec) | |
| 112 | 112 | ||
| 113 | 113 | data := collect(t, reader) | |
| @@ -121,6 +121,22 @@ func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) { | |||
| 121 | 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 | 140 | func TestObserveBackupNextRun(t *testing.T) { | |
| 125 | 141 | m, reader := newTestMetrics(t) | |
| 126 | 142 | 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 uint64 | 88 | + | 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.Count | 90 | + | 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, 200 | 108 | + | rec.StartedAt, rec.SizeBytes = newer, 200 |
| 109 | m.ObserveStoredBackup(rec) | 109 | m.ObserveStoredBackup(rec) | ||
| 110 | - | rec.FinishedAt, rec.SizeBytes = older, 100 | 110 | + | 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 { | |||
| 16 | 16 | // without counting a run, so the last success survives a restart. | |
| 17 | 17 | // Older records never replace a newer one. | |
| 18 | 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 | } | |
| @@ -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 { | |||
| 30 | 30 | StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) | |
| 31 | 31 | GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error) | |
| 32 | 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 | 37 | DeleteVolumeArchive(ctx context.Context, artifactRef string) error | |
| 34 | 38 | ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error) | |
| 35 | 39 | } | |
| @@ -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) error | 37 | 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 ( | |||
| 93 | 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 | 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 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. | |
| 99 | 109 | 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 | |
| 108 | 115 | } | |
| 109 | 116 | ||
| 110 | 117 | // 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 for | 106 | + | // 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 BackupKind | 110 | + | BackupTargetRef |
| 101 | - | App string | 111 | + | Succeeded bool |
| 102 | - | Service string | 112 | + | Duration time.Duration |
| 103 | - | Target string | 113 | + | SizeBytes int64 |
| 104 | - | Succeeded bool | 114 | + | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "strings" | |
| 8 | - | "sync" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -18,160 +17,159 @@ import ( | |||
| 18 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 19 | 18 | ) | |
| 20 | 19 | ||
| 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 | + | ) | |
| 33 | 24 | ||
| 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 | |
| 38 | 32 | } | |
| 39 | 33 | ||
| 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) { | |
| 46 | 35 | state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once() | |
| 47 | 36 | state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{ | |
| 48 | 37 | "api": volumeSpec("api", "data", "/data"), | |
| 49 | 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) { | |
| 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 | + | } | |
| 83 | 75 | } | |
| 84 | 76 | ||
| 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) | |
| 87 | 84 | started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC) | |
| 88 | 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{} | |
| 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())) | |
| 101 | 97 | } | |
| 102 | 98 | ||
| 103 | - | func TestService_RecordsRunMetrics(t *testing.T) { | |
| 104 | - | ctx := context.Background() | |
| 105 | - | state := outmocks.NewMockAppStateReader(t) | |
| 99 | + | func expectShopDatabase(state *outmocks.MockAppStateReader) { | |
| 106 | 100 | state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once() | |
| 107 | 101 | state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{ | |
| 108 | 102 | "api": appSpecWithDatabase("api", "orders", "daily"), | |
| 109 | 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) { | |
| 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 | + | }) | |
| 155 | 152 | } | |
| 156 | 153 | ||
| 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) { | |
| 159 | 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 | 160 | started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC) | |
| 162 | 161 | ||
| 163 | 162 | _, storage, svc := backupTestService(t, state) | |
| 164 | - | metrics := &recordingMetrics{} | |
| 163 | + | metrics := outmocks.NewMockBackupMetrics(t) | |
| 165 | 164 | 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())) | |
| 177 | 175 | } | |
| @@ -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.Mutex | 22 | + | shopOrders = domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders"} |
| 24 | - | runs []domain.BackupRunRecord | 23 | + | ) |
| 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", err | 123 | + | 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 { | |||
| 60 | 60 | return s | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 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. | |
| 65 | 67 | func (s *Service) SeedMetrics(ctx context.Context) error { | |
| 66 | 68 | if s.metrics == nil { | |
| 67 | 69 | 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 | 84 | jobs, err := s.ListBackups(ctx, "") | |
| 70 | 85 | if err != nil { | |
| 71 | 86 | return fmt.Errorf("seed backup metrics: %w", err) | |
| 72 | 87 | } | |
| 73 | 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 | 93 | 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, | |
| 77 | 95 | }) | |
| 78 | 96 | } | |
| 79 | 97 | 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 | 104 | // Targets returns every declared database target of one app, ordered by | |
| 83 | 105 | // service then database. | |
| 84 | 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 | 262 | // 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. | |
| 243 | 264 | 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) | |
| 246 | 267 | if s.metrics != nil { | |
| 247 | 268 | 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, | |
| 250 | 273 | } | |
| 251 | 274 | if result != nil { | |
| 252 | 275 | run.SizeBytes = result.Job.SizeBytes | |
| @@ -256,11 +279,10 @@ func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, s | |||
| 256 | 279 | 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 | 283 | if target.ContainerID == "" { | |
| 261 | 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 | 287 | execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout) | |
| 266 | 288 | defer cancelExec() | |
| @@ -60,25 +60,47 @@ func (s *Service) WithMetrics(metrics out.BackupMetrics) *Service { | |||||
| 60 | return s | 60 | return s | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | - | // SeedMetrics reports the newest stored dump of every target as its last | 63 | + | // 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 nil | 69 | 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 nil | 97 | 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 by | 104 | // 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 service | 262 | // 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.SizeBytes | 275 | run.SizeBytes = result.Job.SizeBytes | ||
| @@ -256,11 +279,10 @@ func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, s | |||||
| 256 | return result, err | 279 | 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 { | |||
| 56 | 56 | return s | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 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. | |
| 61 | 63 | func (s *VolumeService) SeedMetrics(ctx context.Context) error { | |
| 62 | 64 | if s.metrics == nil { | |
| 63 | 65 | return nil | |
| 64 | 66 | } | |
| 65 | - | jobs, err := s.storage.ListVolumeArchives(ctx, "") | |
| 67 | + | targets, err := s.allVolumeTargets(ctx) | |
| 66 | 68 | if err != nil { | |
| 67 | 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 | 89 | 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, | |
| 73 | 91 | }) | |
| 74 | 92 | } | |
| 75 | 93 | 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 | 100 | // VolumeTargets returns every declared volume target of one app. | |
| 79 | 101 | func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) { | |
| 80 | 102 | if s.state == nil { | |
| @@ -246,9 +268,11 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum | |||
| 246 | 268 | job := s.archiveVolume(ctx, target) | |
| 247 | 269 | if s.metrics != nil { | |
| 248 | 270 | 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, | |
| 252 | 276 | }) | |
| 253 | 277 | } | |
| 254 | 278 | return job | |
| @@ -56,25 +56,47 @@ func (s *VolumeService) WithMetrics(metrics out.BackupMetrics) *VolumeService { | |||||
| 56 | return s | 56 | return s | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | - | // SeedMetrics reports the newest stored archive of every target as its | 59 | + | // 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 nil | 65 | 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 nil | 93 | 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 job | 278 | return job | ||
M internal/usecase/backup/volume_service_test.go +9 -0
| @@ -90,6 +90,15 @@ func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([ | |||
| 90 | 90 | 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 | 102 | func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil } | |
| 94 | 103 | ||
| 95 | 104 | 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, nil | 90 | 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) { | ||