syncova-backup/packages/metrics/store.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

515 lines
19 KiB
Go

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
}