package metrics import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // ErrChartNotFound meldet ein unbekanntes Diagramm. var ErrChartNotFound = errors.New("das diagramm ist nicht bekannt") // ErrChartUnavailable meldet ein Diagramm ohne Datengrundlage. var ErrChartUnavailable = errors.New("fuer dieses diagramm gibt es keine datengrundlage") // Store bildet Zeitreihen aus der Control Plane. type Store struct { // connectionPool ist der Datenbankpool der Control Plane. connectionPool *pgxpool.Pool } // NewStore erzeugt die Datenzugriffsschicht. func NewStore(connectionPool *pgxpool.Pool) *Store { return &Store{connectionPool: connectionPool} } // BuildChart erzeugt ein Diagramm fuer den angegebenen Zeitraum. func (store *Store) BuildChart(buildContext context.Context, metricName string, window Window, repositoryFilter *uuid.UUID) (*Chart, error) { chartDefinition, isKnown := FindChartDefinition(metricName) if !isKnown { return nil, fmt.Errorf("%w: %s", ErrChartNotFound, metricName) } if !chartDefinition.Available { // Ein Diagramm ohne Datengrundlage liefert keine leere Kurve, sondern // einen Fehler mit Begruendung. Eine leere Kurve sieht aus wie eine // Anlage ohne Betrieb. return nil, fmt.Errorf("%w: %s", ErrChartUnavailable, chartDefinition.UnavailableReason) } chart := &Chart{ Metric: chartDefinition.Metric, Title: chartDefinition.Title, Description: chartDefinition.Description, Window: window, } var buildError error switch metricName { case MetricBackupOutcome: chart.Series, buildError = store.buildBackupOutcomeSeries(buildContext, window) case MetricBackupDuration: chart.Series, buildError = store.buildBackupDurationSeries(buildContext, window) case MetricBackupThroughput: chart.Series, buildError = store.buildBackupThroughputSeries(buildContext, window) case MetricBackupVolume: chart.Series, buildError = store.buildBackupVolumeSeries(buildContext, window) case MetricStorageGrowth: chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleStorageUsed, "Belegt", UnitBytes, repositoryFilter) case MetricSecurityScore: chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleSecurityScore, "Sicherheitsbewertung", UnitPercent, nil) case MetricRepositoryCapacity: chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleStorageUsedPercent, "Auslastung", UnitPercent, repositoryFilter) case MetricDeduplication: chart.Series, buildError = store.buildSavingsSeries(buildContext, window, true) case MetricCompression: chart.Series, buildError = store.buildSavingsSeries(buildContext, window, false) case MetricRecoveryPointAge: chart.Series, buildError = store.buildRecoveryPointAgeSeries(buildContext, window) case MetricRecoveryDuration: chart.Series, buildError = store.buildRecoveryDurationSeries(buildContext, window) case MetricVerificationOutcome: chart.Series, buildError = store.buildVerificationOutcomeSeries(buildContext, window) default: return nil, fmt.Errorf("%w: %s", ErrChartNotFound, metricName) } if buildError != nil { return nil, buildError } chart.AddAssessmentNote() return chart, nil } // bucketedRow ist eine Zeile einer nach Zeitfenstern gruppierten Abfrage. type bucketedRow struct { // BucketStart ist der Beginn des Zeitfensters. BucketStart time.Time // Value ist der aggregierte Wert. Value float64 // SampleCount ist die Zahl der eingeflossenen Zeilen. SampleCount int } // queryBuckets fuehrt eine nach Zeitfenstern gruppierte Abfrage aus. // // Die Gruppierung geschieht in PostgreSQL ueber date_bin und nicht in Go: Eine // Million Laufzeilen in den Dienst zu holen, um sie dort zu zaehlen, waere ein // Vielfaches an Arbeit fuer dasselbe Ergebnis. func (store *Store) queryBuckets(queryContext context.Context, queryStatement string, arguments ...any) ([]bucketedRow, error) { resultRows, queryError := store.connectionPool.Query(queryContext, queryStatement, arguments...) if queryError != nil { return nil, fmt.Errorf("die zeitreihe konnte nicht gebildet werden: %w", queryError) } defer resultRows.Close() collectedRows := make([]bucketedRow, 0, 64) for resultRows.Next() { var collectedRow bucketedRow if scanError := resultRows.Scan(&collectedRow.BucketStart, &collectedRow.Value, &collectedRow.SampleCount); scanError != nil { return nil, fmt.Errorf("ein punkt der zeitreihe konnte nicht gelesen werden: %w", scanError) } collectedRows = append(collectedRows, collectedRow) } return collectedRows, resultRows.Err() } // fillSeries traegt die Abfrageergebnisse in die Punkte eines Fensters ein. // // Zeitfenster ohne Zeile bleiben ohne Wert. Genau hier entscheidet sich, ob aus // „nichts lief" eine Null wird. func fillSeries(seriesName string, seriesLabel string, seriesUnit SeriesUnit, window Window, collectedRows []bucketedRow) Series { points := BuildEmptyPoints(window) bucketCount := len(points) for _, collectedRow := range collectedRows { bucketIndex, isInsideWindow := BucketIndexOf(window, collectedRow.BucketStart, bucketCount) if !isInsideWindow { continue } points[bucketIndex].Value = collectedRow.Value points[bucketIndex].HasValue = true points[bucketIndex].SampleCount = collectedRow.SampleCount } return Series{Name: seriesName, Label: seriesLabel, Unit: seriesUnit, Points: points} } // buildBackupOutcomeSeries bildet Erfolg und Fehlschlag der Sicherungen. func (store *Store) buildBackupOutcomeSeries(buildContext context.Context, window Window) ([]Series, error) { // Drei Reihen aus einer Abfrage: Sie teilen sich dieselben Zeitfenster, und // drei getrennte Abfragen laesen dieselbe Tabelle dreimal. const outcomeStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, count(*) FILTER (WHERE status = 'succeeded'), count(*) FILTER (WHERE status = 'partial_failure'), count(*) FILTER (WHERE status = 'failed') FROM backup_job_runs WHERE completed_at >= $1 AND completed_at < $2 AND status IN ('succeeded', 'partial_failure', 'failed') GROUP BY bucket ORDER BY bucket` resultRows, queryError := store.connectionPool.Query(buildContext, outcomeStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, fmt.Errorf("die ergebnisse der sicherungen konnten nicht gelesen werden: %w", queryError) } defer resultRows.Close() succeededRows := make([]bucketedRow, 0, 64) partialRows := make([]bucketedRow, 0, 8) failedRows := make([]bucketedRow, 0, 8) for resultRows.Next() { var ( bucketStart time.Time succeededCount int partialCount int failedCount int ) if scanError := resultRows.Scan(&bucketStart, &succeededCount, &partialCount, &failedCount); scanError != nil { return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError) } totalCount := succeededCount + partialCount + failedCount succeededRows = append(succeededRows, bucketedRow{bucketStart, float64(succeededCount), totalCount}) partialRows = append(partialRows, bucketedRow{bucketStart, float64(partialCount), totalCount}) failedRows = append(failedRows, bucketedRow{bucketStart, float64(failedCount), totalCount}) } if rowsError := resultRows.Err(); rowsError != nil { return nil, rowsError } // Die Reihenfolge ist die Anzeigereihenfolge: Erfolg unten, Befunde darauf. return []Series{ fillSeries("succeeded", "Erfolgreich", UnitCount, window, succeededRows), fillSeries("partial_failure", "Teilfehler", UnitCount, window, partialRows), fillSeries("failed", "Gescheitert", UnitCount, window, failedRows), }, nil } // buildBackupDurationSeries bildet die Dauer der Sicherungslaeufe. func (store *Store) buildBackupDurationSeries(buildContext context.Context, window Window) ([]Series, error) { // Abgebrochene Laeufe bleiben aussen vor: Ihre Dauer ist die Zeit bis zum // Abbruch und sagt nichts ueber die Leistung der Anlage. const durationStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, avg(EXTRACT(EPOCH FROM (completed_at - started_at)))::float8, count(*)::int FROM backup_job_runs WHERE completed_at >= $1 AND completed_at < $2 AND started_at IS NOT NULL AND status IN ('succeeded', 'partial_failure') GROUP BY bucket ORDER BY bucket` collectedRows, queryError := store.queryBuckets(buildContext, durationStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, queryError } return []Series{fillSeries("duration", "Mittlere Dauer", UnitSeconds, window, collectedRows)}, nil } // buildBackupThroughputSeries bildet den Durchsatz der Sicherungslaeufe. func (store *Store) buildBackupThroughputSeries(buildContext context.Context, window Window) ([]Series, error) { // Der Durchsatz wird aus Menge und Laufzeit **neu berechnet** und nicht aus // throughput_bps gelesen: Die gespeicherte Spalte bleibt bei kurzen Laeufen // leer, und ein Mittelwert ueber teils fehlende Werte waere schief. // // Laeufe unter einer Sekunde bleiben aussen vor — bei ihnen bestimmt die // Messungenauigkeit das Ergebnis, nicht die Leistung. const throughputStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, (sum(bytes_processed) / NULLIF(sum(EXTRACT(EPOCH FROM (completed_at - started_at))), 0))::float8, count(*)::int FROM backup_job_runs WHERE completed_at >= $1 AND completed_at < $2 AND started_at IS NOT NULL AND status IN ('succeeded', 'partial_failure') AND EXTRACT(EPOCH FROM (completed_at - started_at)) >= 1 AND bytes_processed > 0 GROUP BY bucket HAVING sum(EXTRACT(EPOCH FROM (completed_at - started_at))) > 0 ORDER BY bucket` collectedRows, queryError := store.queryBuckets(buildContext, throughputStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, queryError } return []Series{ fillSeries("throughput", "Mittlerer Durchsatz", UnitBytesPerSecond, window, collectedRows), }, nil } // buildBackupVolumeSeries bildet verarbeitete und geschriebene Datenmenge. func (store *Store) buildBackupVolumeSeries(buildContext context.Context, window Window) ([]Series, error) { const volumeStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, sum(bytes_processed)::float8, sum(bytes_written)::float8, count(*)::int FROM backup_job_runs WHERE completed_at >= $1 AND completed_at < $2 AND status IN ('succeeded', 'partial_failure') GROUP BY bucket ORDER BY bucket` resultRows, queryError := store.connectionPool.Query(buildContext, volumeStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, fmt.Errorf("die datenmengen konnten nicht gelesen werden: %w", queryError) } defer resultRows.Close() processedRows := make([]bucketedRow, 0, 64) writtenRows := make([]bucketedRow, 0, 64) for resultRows.Next() { var ( bucketStart time.Time processedBytes float64 writtenBytes float64 runCount int ) if scanError := resultRows.Scan(&bucketStart, &processedBytes, &writtenBytes, &runCount); scanError != nil { return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError) } processedRows = append(processedRows, bucketedRow{bucketStart, processedBytes, runCount}) writtenRows = append(writtenRows, bucketedRow{bucketStart, writtenBytes, runCount}) } if rowsError := resultRows.Err(); rowsError != nil { return nil, rowsError } return []Series{ fillSeries("processed", "Gelesen", UnitBytes, window, processedRows), fillSeries("written", "Abgelegt", UnitBytes, window, writtenRows), }, nil } // buildSavingsSeries bildet die Ersparnis durch Deduplizierung oder Kompression. func (store *Store) buildSavingsSeries(buildContext context.Context, window Window, isDeduplication bool) ([]Series, error) { // Deduplizierung: wie viel der Ursprungsdaten gar nicht erst abgelegt wurde. // Kompression: wie viel die Kompression **zusaetzlich** einspart, gemessen // an den nach Deduplizierung verbliebenen Daten. Beide Anteile getrennt zu // fuehren ist der einzige Weg, sie nicht doppelt zu zaehlen. savingsExpression := ` (1 - sum(unique_bytes)::float8 / NULLIF(sum(logical_bytes), 0)) * 100` filterExpression := "logical_bytes > 0 AND unique_bytes IS NOT NULL" if !isDeduplication { savingsExpression = ` (1 - sum(compressed_bytes)::float8 / NULLIF(sum(unique_bytes), 0)) * 100` filterExpression = "unique_bytes > 0 AND compressed_bytes IS NOT NULL" } savingsStatement := fmt.Sprintf(` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, %s, count(*)::int FROM backups WHERE completed_at >= $1 AND completed_at < $2 AND status = 'complete' AND deleted_at IS NULL AND %s GROUP BY bucket ORDER BY bucket`, savingsExpression, filterExpression) collectedRows, queryError := store.queryBuckets(buildContext, savingsStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, queryError } seriesLabel := "Ersparnis durch Deduplizierung" seriesName := "deduplication_savings" if !isDeduplication { seriesLabel = "Zusaetzliche Ersparnis durch Kompression" seriesName = "compression_savings" } return []Series{fillSeries(seriesName, seriesLabel, UnitPercent, window, collectedRows)}, nil } // buildRecoveryPointAgeSeries bildet das Alter des juengsten Wiederherstellungspunkts. func (store *Store) buildRecoveryPointAgeSeries(buildContext context.Context, window Window) ([]Series, error) { // Anders als die uebrigen Reihen entsteht diese nicht durch Gruppieren: // Gefragt ist fuer **jedes** Zeitfenster der Abstand zum bis dahin juengsten // Backup — auch dann, wenn in diesem Fenster keines lief. Genau das ist der // Sinn der Kurve: Sie steigt, solange nichts gesichert wird. const ageStatement = ` WITH fenster AS ( SELECT generate_series($1::timestamptz, $2::timestamptz - $3::interval, $3::interval) AS bucket ) SELECT f.bucket, EXTRACT(EPOCH FROM (f.bucket - ( SELECT max(b.completed_at) FROM backups b WHERE b.status = 'complete' AND b.deleted_at IS NULL AND b.completed_at <= f.bucket )))::float8 AS alter_sekunden, 1 AS anzahl FROM fenster f ORDER BY f.bucket` collectedRows, queryError := store.queryBucketsAllowingNull(buildContext, ageStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, queryError } return []Series{ fillSeries("recovery_point_age", "Alter des juengsten Punktes", UnitSeconds, window, collectedRows), }, nil } // queryBucketsAllowingNull liest Zeitfenster, in denen der Wert fehlen darf. // // Ein NULL bedeutet hier „zu diesem Zeitpunkt gab es noch kein Backup". Die // Zeile wird uebersprungen, statt eine Null einzutragen: Vor dem ersten Backup // hat der juengste Wiederherstellungspunkt kein Alter, nicht das Alter null. func (store *Store) queryBucketsAllowingNull(queryContext context.Context, queryStatement string, arguments ...any) ([]bucketedRow, error) { resultRows, queryError := store.connectionPool.Query(queryContext, queryStatement, arguments...) if queryError != nil { return nil, fmt.Errorf("die zeitreihe konnte nicht gebildet werden: %w", queryError) } defer resultRows.Close() collectedRows := make([]bucketedRow, 0, 64) for resultRows.Next() { var ( bucketStart time.Time nullableVal *float64 sampleCount int ) if scanError := resultRows.Scan(&bucketStart, &nullableVal, &sampleCount); scanError != nil { return nil, fmt.Errorf("ein punkt der zeitreihe konnte nicht gelesen werden: %w", scanError) } if nullableVal == nil { continue } collectedRows = append(collectedRows, bucketedRow{bucketStart, *nullableVal, sampleCount}) } return collectedRows, resultRows.Err() } // buildRecoveryDurationSeries bildet die Dauer der Wiederherstellungen. func (store *Store) buildRecoveryDurationSeries(buildContext context.Context, window Window) ([]Series, error) { const durationStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, avg(EXTRACT(EPOCH FROM (completed_at - started_at)))::float8, count(*)::int FROM restore_jobs WHERE completed_at >= $1 AND completed_at < $2 AND started_at IS NOT NULL AND status IN ('succeeded', 'partial_failure') GROUP BY bucket ORDER BY bucket` collectedRows, queryError := store.queryBuckets(buildContext, durationStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, queryError } return []Series{ fillSeries("recovery_duration", "Mittlere Dauer", UnitSeconds, window, collectedRows), }, nil } // buildVerificationOutcomeSeries bildet die Ergebnisse der Pruefungen. func (store *Store) buildVerificationOutcomeSeries(buildContext context.Context, window Window) ([]Series, error) { const outcomeStatement = ` SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket, count(*) FILTER (WHERE result = 'clean'), count(*) FILTER (WHERE result IN ('corrupted', 'incomplete')), count(*) FILTER (WHERE status = 'failed') FROM verification_jobs WHERE completed_at >= $1 AND completed_at < $2 GROUP BY bucket ORDER BY bucket` resultRows, queryError := store.connectionPool.Query(buildContext, outcomeStatement, window.From, window.To, window.BucketWidth.String()) if queryError != nil { return nil, fmt.Errorf("die pruefergebnisse konnten nicht gelesen werden: %w", queryError) } defer resultRows.Close() cleanRows := make([]bucketedRow, 0, 16) findingRows := make([]bucketedRow, 0, 8) failedRows := make([]bucketedRow, 0, 8) for resultRows.Next() { var ( bucketStart time.Time cleanCount int findingCount int failedCount int ) if scanError := resultRows.Scan(&bucketStart, &cleanCount, &findingCount, &failedCount); scanError != nil { return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError) } totalCount := cleanCount + findingCount + failedCount cleanRows = append(cleanRows, bucketedRow{bucketStart, float64(cleanCount), totalCount}) findingRows = append(findingRows, bucketedRow{bucketStart, float64(findingCount), totalCount}) failedRows = append(failedRows, bucketedRow{bucketStart, float64(failedCount), totalCount}) } if rowsError := resultRows.Err(); rowsError != nil { return nil, rowsError } // „Nicht durchfuehrbar" steht neben „Befund" und wird nicht mit ihm // vermengt: Dass eine Pruefung nicht laufen konnte, sagt nichts ueber das // Backup (Phase 10). return []Series{ fillSeries("clean", "Ohne Beanstandung", UnitCount, window, cleanRows), fillSeries("finding", "Mit Befund", UnitCount, window, findingRows), fillSeries("failed", "Nicht durchfuehrbar", UnitCount, window, failedRows), }, nil }