syncova-backup/packages/backupengine/progress.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

229 lines
8.6 KiB
Go

package backupengine
import (
"sync"
"sync/atomic"
"time"
)
// Progress beschreibt den Stand eines laufenden Backups (PROMPT.md §34).
type Progress struct {
// BytesProcessed ist die Menge der gelesenen Ursprungsdaten.
BytesProcessed int64 `json:"bytes_processed"`
// BytesWritten ist die Menge der tatsächlich abgelegten Daten.
BytesWritten int64 `json:"bytes_written"`
// BytesDeduplicated ist die durch Deduplizierung eingesparte Menge.
BytesDeduplicated int64 `json:"bytes_deduplicated"`
// ChunksWritten ist die Zahl neu geschriebener Blöcke.
ChunksWritten int64 `json:"chunks_written"`
// ChunksDeduplicated ist die Zahl bereits vorhandener Blöcke.
ChunksDeduplicated int64 `json:"chunks_deduplicated"`
// BytesReused ist die Datenmenge, die dank Zusatzsicherung gar nicht
// gelesen werden musste.
//
// Sie steht bewusst neben BytesDeduplicated und nicht darin: ein
// deduplizierter Block wurde gelesen und gehasht, ein übernommener nicht
// einmal geöffnet. Die beiden Zahlen zu vermengen verschleierte genau den
// Gewinn, um dessentwillen es Zusatzsicherungen gibt.
BytesReused int64 `json:"bytes_reused"`
// ChunksReused ist die Zahl übernommener Blöcke.
ChunksReused int64 `json:"chunks_reused"`
// ChunksIncompressible ist die Zahl neu geschriebener, nicht verkleinerbarer Blöcke.
//
// Der Entropie-Indikator (Phase 16). Gezählt werden nur **neue** Blöcke:
// Ein deduplizierter stammt aus einem früheren Lauf und sagt nichts über
// die Daten von heute.
ChunksIncompressible int64 `json:"chunks_incompressible"`
// ElapsedSeconds ist die bisher verstrichene Zeit.
ElapsedSeconds float64 `json:"elapsed_seconds"`
// ThroughputBytesPerSecond ist der aktuelle Durchsatz auf die Ursprungsdaten bezogen.
ThroughputBytesPerSecond float64 `json:"throughput_bytes_per_second"`
}
// CompressionRatio liefert das Verhältnis von Ursprungs- zu abgelegten Daten.
//
// Der zweite Rückgabewert meldet, ob ein endliches Verhältnis existiert. Wurde
// nichts abgelegt, weil jeder Block bereits vorlag, gibt es keines — ein
// stillschweigendes 0 stellte den besten Fall als den schlechtesten dar.
func (progress Progress) CompressionRatio() (ratio float64, isDefined bool) {
if progress.BytesProcessed == 0 || progress.BytesWritten == 0 {
return 0, false
}
return float64(progress.BytesProcessed) / float64(progress.BytesWritten), true
}
// SavingsPercentage liefert den Anteil eingesparter Daten in Prozent.
//
// Die Kennzahl ist immer bestimmbar und deshalb für Anzeigen die verlässlichere.
func (progress Progress) SavingsPercentage() float64 {
if progress.BytesProcessed == 0 {
return 0
}
savedBytes := progress.BytesProcessed - progress.BytesWritten
if savedBytes < 0 {
// Bei sehr kleinen oder bereits komprimierten Daten kann die abgelegte
// Menge die Ursprungsmenge übersteigen. Eine negative Ersparnis
// auszuweisen wäre verwirrend; 0 ist die ehrliche Aussage.
return 0
}
return float64(savedBytes) / float64(progress.BytesProcessed) * 100
}
// ProgressCallback wird während eines Backups mit dem Fortschritt aufgerufen.
//
// Die Funktion muss schnell zurückkehren: sie läuft im Takt der Verarbeitung
// und würde die Pipeline sonst ausbremsen.
type ProgressCallback func(progress Progress)
// ProgressReporter sammelt die Kennzahlen eines laufenden Backups.
//
// Die Zähler sind atomar, weil mehrere Arbeiter gleichzeitig melden.
type ProgressReporter struct {
// bytesProcessed zählt die gelesenen Ursprungsdaten.
bytesProcessed atomic.Int64
// bytesWritten zählt die abgelegten Daten.
bytesWritten atomic.Int64
// bytesDeduplicated zählt die eingesparten Daten.
bytesDeduplicated atomic.Int64
// chunksWritten zählt die neu geschriebenen Blöcke.
chunksWritten atomic.Int64
// chunksDeduplicated zählt die bereits vorhandenen Blöcke.
chunksDeduplicated atomic.Int64
// bytesReused zählt die ungelesen übernommenen Bytes.
bytesReused atomic.Int64
// chunksReused zählt die ungelesen übernommenen Blöcke.
chunksReused atomic.Int64
// chunksIncompressible zählt die neu geschriebenen, nicht verkleinerbaren Blöcke.
//
// Der Entropie-Indikator der Anlage (Phase 16): Normale Nutzdaten lassen
// sich fast immer verkleinern, verschlüsselte nie. Ein Sprung in diesem
// Anteil ist das billigste verlässliche Signal für massenhafte
// Verschlüsselung.
chunksIncompressible atomic.Int64
// startTime ist der Beginn des Backups.
startTime time.Time
// callback meldet den Fortschritt nach außen.
callback ProgressCallback
// callbackInterval ist der Mindestabstand zweier Meldungen.
callbackInterval time.Duration
// callbackMutex schützt den Zeitpunkt der letzten Meldung.
callbackMutex sync.Mutex
// lastCallbackTime ist der Zeitpunkt der letzten Meldung.
lastCallbackTime time.Time
}
// defaultProgressInterval ist der Mindestabstand zweier Fortschrittsmeldungen.
//
// Ohne Drosselung entstünde bei kleinen Blöcken eine Flut von Aufrufen, die
// mehr Zeit kostete als die eigentliche Arbeit.
const defaultProgressInterval = 500 * time.Millisecond
// NewProgressReporter erzeugt einen Fortschrittszähler.
func NewProgressReporter(callback ProgressCallback, callbackInterval time.Duration) *ProgressReporter {
if callbackInterval <= 0 {
callbackInterval = defaultProgressInterval
}
return &ProgressReporter{
startTime: time.Now(),
callback: callback,
callbackInterval: callbackInterval,
lastCallbackTime: time.Now(),
}
}
// recordWrittenChunk vermerkt einen neu geschriebenen Block.
func (progressReporter *ProgressReporter) recordWrittenChunk(logicalBytes int64, storedBytes int64) {
progressReporter.bytesProcessed.Add(logicalBytes)
progressReporter.bytesWritten.Add(storedBytes)
progressReporter.chunksWritten.Add(1)
progressReporter.maybeReport()
}
// recordIncompressibleChunk vermerkt einen nicht verkleinerbaren Block.
func (progressReporter *ProgressReporter) recordIncompressibleChunk() {
progressReporter.chunksIncompressible.Add(1)
}
// recordDeduplicatedChunk vermerkt einen bereits vorhandenen Block.
func (progressReporter *ProgressReporter) recordDeduplicatedChunk(logicalBytes int64) {
progressReporter.bytesProcessed.Add(logicalBytes)
progressReporter.bytesDeduplicated.Add(logicalBytes)
progressReporter.chunksDeduplicated.Add(1)
progressReporter.maybeReport()
}
// recordReusedChunk vermerkt einen aus dem Elternbackup übernommenen Block.
//
// Er zählt nicht zu den verarbeiteten Bytes: gelesen wurde er nie. Stünde er
// dort, gäbe der Durchsatz eine Leseleistung aus, die es nicht gab.
func (progressReporter *ProgressReporter) recordReusedChunk(logicalBytes int64) {
progressReporter.bytesReused.Add(logicalBytes)
progressReporter.chunksReused.Add(1)
progressReporter.maybeReport()
}
// maybeReport meldet den Fortschritt, sofern der Mindestabstand verstrichen ist.
func (progressReporter *ProgressReporter) maybeReport() {
if progressReporter.callback == nil {
return
}
progressReporter.callbackMutex.Lock()
if time.Since(progressReporter.lastCallbackTime) < progressReporter.callbackInterval {
progressReporter.callbackMutex.Unlock()
return
}
progressReporter.lastCallbackTime = time.Now()
progressReporter.callbackMutex.Unlock()
// Die Meldung erfolgt außerhalb der Sperre: eine langsame Rückrufmethode
// soll die übrigen Arbeiter nicht blockieren.
progressReporter.callback(progressReporter.Snapshot())
}
// Snapshot liefert den aktuellen Stand.
func (progressReporter *ProgressReporter) Snapshot() Progress {
elapsedDuration := time.Since(progressReporter.startTime)
elapsedSeconds := elapsedDuration.Seconds()
bytesProcessed := progressReporter.bytesProcessed.Load()
var throughput float64
if elapsedSeconds > 0 {
throughput = float64(bytesProcessed) / elapsedSeconds
}
return Progress{
BytesProcessed: bytesProcessed,
BytesWritten: progressReporter.bytesWritten.Load(),
BytesDeduplicated: progressReporter.bytesDeduplicated.Load(),
ChunksWritten: progressReporter.chunksWritten.Load(),
ChunksDeduplicated: progressReporter.chunksDeduplicated.Load(),
BytesReused: progressReporter.bytesReused.Load(),
ChunksReused: progressReporter.chunksReused.Load(),
ChunksIncompressible: progressReporter.chunksIncompressible.Load(),
ElapsedSeconds: elapsedSeconds,
ThroughputBytesPerSecond: throughput,
}
}
// ReportFinal meldet den Endstand unabhängig vom Mindestabstand.
func (progressReporter *ProgressReporter) ReportFinal() Progress {
finalProgress := progressReporter.Snapshot()
if progressReporter.callback != nil {
progressReporter.callback(finalProgress)
}
return finalProgress
}