syncova-backup/packages/platform/ratelimit/bandwidth.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

329 lines
10 KiB
Go

// Package ratelimit begrenzt den Datendurchsatz eines Sicherungslaufs.
//
// Der Zweck ist betrieblich, nicht technisch: Eine Sicherung, die eine
// Standleitung vollständig belegt, legt die Anwendungen lahm, für deren Schutz
// sie läuft. Ein Backup, das man abschalten muss, um arbeiten zu können, wird
// abgeschaltet.
package ratelimit
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
)
// Unlimited steht für einen abgeschalteten Begrenzer.
const Unlimited int64 = 0
// minimumBurstBytes ist die kleinste sinnvolle Eimergröße.
//
// Kleiner als ein typischer Netzwerkblock zu begrenzen führt zu ständigem
// Warten in winzigen Schritten: Der Durchsatz bräche stärker ein als vorgesehen,
// weil jeder Aufruf mehr Zeit mit Warten als mit Übertragen verbrächte.
const minimumBurstBytes int64 = 32 * 1024
// defaultBurstDuration bestimmt die Eimergröße aus der Rate.
//
// Eine Sekunde Vorrat ist der übliche Ausgleich: Er glättet kurze Spitzen,
// ohne dass sich über längere Zeit mehr als die vereinbarte Rate ansammelt.
const defaultBurstDuration = time.Second
// Limiter begrenzt den Durchsatz nach dem Token-Bucket-Verfahren.
//
// Gewählt wurde das Token-Bucket-Verfahren und kein festes Zeitfenster: Ein
// Fenster erlaubt an seiner Grenze die doppelte Rate — 10 MB am Ende des einen
// und 10 MB am Anfang des nächsten Fensters ergeben 20 MB in kurzer Folge. Der
// Eimer verhindert das, weil er kontinuierlich nachfüllt.
type Limiter struct {
// mutex schützt den Zustand.
//
// Ein Begrenzer wird von allen Arbeitern der Pipeline gemeinsam genutzt;
// je Arbeiter einen eigenen zu führen ergäbe ein Vielfaches der Rate.
mutex sync.Mutex
// bytesPerSecond ist die zugelassene Rate.
bytesPerSecond int64
// burstBytes ist die Größe des Eimers.
burstBytes int64
// availableTokens ist der aktuelle Füllstand in Byte.
availableTokens float64
// lastRefillTime ist der Zeitpunkt der letzten Nachfüllung.
lastRefillTime time.Time
// nowFunction liefert die aktuelle Zeit.
//
// Austauschbar, damit Tests das Zeitverhalten prüfen können, ohne
// tatsächlich zu warten. Ein Test, der echte Sekunden verstreichen lässt,
// wird entweder langsam oder unzuverlässig.
nowFunction func() time.Time
// sleepFunction wartet eine Dauer ab.
sleepFunction func(waitContext context.Context, waitDuration time.Duration) error
}
// ErrInvalidRate meldet eine unbrauchbare Rate.
var ErrInvalidRate = errors.New("die bandbreitengrenze ist unbrauchbar")
// NewLimiter erzeugt einen Begrenzer.
//
// Eine Rate von Unlimited ergibt einen Begrenzer, der nichts begrenzt. Das ist
// bequemer als überall zu prüfen, ob überhaupt einer vorhanden ist — und
// verhindert den Fehler, den Begrenzer versehentlich zu übergehen.
func NewLimiter(bytesPerSecond int64) (*Limiter, error) {
if bytesPerSecond < 0 {
return nil, fmt.Errorf("%w: %d byte je sekunde", ErrInvalidRate, bytesPerSecond)
}
if bytesPerSecond > 0 && bytesPerSecond < minimumBurstBytes {
return nil, fmt.Errorf("%w: weniger als %d byte je sekunde sind nicht sinnvoll begrenzbar",
ErrInvalidRate, minimumBurstBytes)
}
burstBytes := int64(float64(bytesPerSecond) * defaultBurstDuration.Seconds())
if burstBytes < minimumBurstBytes {
burstBytes = minimumBurstBytes
}
return &Limiter{
bytesPerSecond: bytesPerSecond,
burstBytes: burstBytes,
availableTokens: float64(burstBytes),
lastRefillTime: time.Now(),
nowFunction: time.Now,
sleepFunction: sleepWithContext,
}, nil
}
// IsUnlimited meldet einen abgeschalteten Begrenzer.
func (limiter *Limiter) IsUnlimited() bool {
return limiter == nil || limiter.bytesPerSecond == Unlimited
}
// BytesPerSecond liefert die eingestellte Rate.
func (limiter *Limiter) BytesPerSecond() int64 {
if limiter == nil {
return Unlimited
}
return limiter.bytesPerSecond
}
// Wait wartet, bis die angegebene Menge übertragen werden darf.
//
// Der Kontext wird beachtet: Ein Abbruch während einer langen Wartezeit muss
// wirken. Andernfalls hinge ein abgebrochener Sicherungslauf noch minutenlang
// in einer Wartezeit, die niemand mehr braucht.
func (limiter *Limiter) Wait(waitContext context.Context, byteCount int64) error {
if limiter.IsUnlimited() || byteCount <= 0 {
return waitContext.Err()
}
// Eine Anforderung größer als der Eimer liesse sich nie erfüllen und
// blockierte für immer. Sie wird in Teilstücke zerlegt.
remainingBytes := byteCount
for remainingBytes > 0 {
if contextError := waitContext.Err(); contextError != nil {
return contextError
}
requestedBytes := remainingBytes
if requestedBytes > limiter.burstBytes {
requestedBytes = limiter.burstBytes
}
waitDuration := limiter.reserve(requestedBytes)
if waitDuration > 0 {
if sleepError := limiter.sleepFunction(waitContext, waitDuration); sleepError != nil {
return sleepError
}
}
remainingBytes -= requestedBytes
}
return nil
}
// reserve entnimmt Token und liefert die nötige Wartezeit.
func (limiter *Limiter) reserve(byteCount int64) time.Duration {
limiter.mutex.Lock()
defer limiter.mutex.Unlock()
currentTime := limiter.nowFunction()
elapsedSeconds := currentTime.Sub(limiter.lastRefillTime).Seconds()
if elapsedSeconds > 0 {
limiter.availableTokens += elapsedSeconds * float64(limiter.bytesPerSecond)
if limiter.availableTokens > float64(limiter.burstBytes) {
limiter.availableTokens = float64(limiter.burstBytes)
}
}
limiter.lastRefillTime = currentTime
// Die Token werden auch dann entnommen, wenn der Eimer leer ist: Der
// Füllstand wird negativ und die Wartezeit ergibt sich daraus. So teilen
// mehrere Arbeiter die Rate gerecht, statt dass der erste alles bekommt und
// die übrigen wiederholt leer ausgehen.
limiter.availableTokens -= float64(byteCount)
if limiter.availableTokens >= 0 {
return 0
}
secondsToWait := -limiter.availableTokens / float64(limiter.bytesPerSecond)
return time.Duration(secondsToWait * float64(time.Second))
}
// sleepWithContext wartet und beachtet dabei einen Abbruch.
func sleepWithContext(waitContext context.Context, waitDuration time.Duration) error {
waitTimer := time.NewTimer(waitDuration)
defer waitTimer.Stop()
select {
case <-waitContext.Done():
return waitContext.Err()
case <-waitTimer.C:
return nil
}
}
// LimitedReader begrenzt den Durchsatz eines Datenstroms.
type LimitedReader struct {
// innerReader ist die eigentliche Quelle.
innerReader io.Reader
// limiter ist der gemeinsame Begrenzer.
limiter *Limiter
// readContext erlaubt den Abbruch während einer Wartezeit.
//
// io.Reader kennt keinen Kontext; ohne dieses Feld liesse sich ein
// wartender Lesevorgang nicht beenden.
readContext context.Context
}
// NewLimitedReader umhüllt einen Datenstrom mit einer Bandbreitengrenze.
func NewLimitedReader(readContext context.Context, innerReader io.Reader, limiter *Limiter) io.Reader {
// Ohne Begrenzung wird der Datenstrom unverändert durchgereicht: eine Hülle,
// die nichts tut, kostet bei jedem Block einen Aufruf mehr.
if limiter.IsUnlimited() {
return innerReader
}
return &LimitedReader{
innerReader: innerReader,
limiter: limiter,
readContext: readContext,
}
}
// Read liest und wartet entsprechend der Bandbreitengrenze.
//
// Gewartet wird **nach** dem Lesen, nicht davor. Vorher zu warten hiesse, für
// eine Menge zu zahlen, die möglicherweise gar nicht kommt — am Ende einer
// Datei wartete der Lauf für Bytes, die es nicht mehr gibt.
func (limitedReader *LimitedReader) Read(targetBuffer []byte) (int, error) {
readCount, readError := limitedReader.innerReader.Read(targetBuffer)
if readCount > 0 {
if waitError := limitedReader.limiter.Wait(limitedReader.readContext, int64(readCount)); waitError != nil {
// Der Abbruch wiegt schwerer als ein gleichzeitiges Leseende: Wer
// abbricht, will kein Ergebnis mehr.
return readCount, waitError
}
}
return readCount, readError
}
// LimitedWriter begrenzt den Durchsatz eines Schreibziels.
type LimitedWriter struct {
// innerWriter ist das eigentliche Ziel.
innerWriter io.Writer
// limiter ist der gemeinsame Begrenzer.
limiter *Limiter
// writeContext erlaubt den Abbruch während einer Wartezeit.
writeContext context.Context
}
// NewLimitedWriter umhüllt ein Schreibziel mit einer Bandbreitengrenze.
func NewLimitedWriter(writeContext context.Context, innerWriter io.Writer, limiter *Limiter) io.Writer {
if limiter.IsUnlimited() {
return innerWriter
}
return &LimitedWriter{
innerWriter: innerWriter,
limiter: limiter,
writeContext: writeContext,
}
}
// Write schreibt und wartet entsprechend der Bandbreitengrenze.
//
// Hier wird **vor** dem Schreiben gewartet: Die Menge steht bereits fest, und
// ein Schreibvorgang, der erst hinterher wartet, hätte die Leitung schon
// belegt.
func (limitedWriter *LimitedWriter) Write(sourceBuffer []byte) (int, error) {
if waitError := limitedWriter.limiter.Wait(limitedWriter.writeContext, int64(len(sourceBuffer))); waitError != nil {
return 0, waitError
}
return limitedWriter.innerWriter.Write(sourceBuffer)
}
// ParseBandwidthLimit liest eine Bandbreitenangabe wie "50MB" oder "1Gbit".
//
// Die doppelte Schreibweise ist Absicht: Netzwerkleute rechnen in Bit je
// Sekunde, Speicherleute in Byte. Wer „100Mbit" meint und „100MB" einträgt,
// vergibt das Achtfache — deshalb werden beide Einheiten angenommen und
// auseinandergehalten.
func ParseBandwidthLimit(limitText string) (int64, error) {
trimmedText := trimSpaceAndLower(limitText)
if trimmedText == "" || trimmedText == "0" || trimmedText == "unbegrenzt" {
return Unlimited, nil
}
unitSuffixes := []struct {
// suffix ist die erkannte Einheit.
suffix string
// multiplier ist der Umrechnungsfaktor in Byte je Sekunde.
multiplier float64
}{
{"gbit", 1000 * 1000 * 1000 / 8},
{"mbit", 1000 * 1000 / 8},
{"kbit", 1000 / 8},
{"gb", 1 << 30},
{"mb", 1 << 20},
{"kb", 1 << 10},
{"b", 1},
}
for _, unitSuffix := range unitSuffixes {
numericPart, hasSuffix := cutSuffix(trimmedText, unitSuffix.suffix)
if !hasSuffix {
continue
}
numericValue, parseError := parseFloatValue(numericPart)
if parseError != nil {
return 0, fmt.Errorf("%w: %q enthält keine gültige zahl", ErrInvalidRate, limitText)
}
return int64(numericValue * unitSuffix.multiplier), nil
}
// Ohne Einheit wird Byte je Sekunde angenommen.
numericValue, parseError := parseFloatValue(trimmedText)
if parseError != nil {
return 0, fmt.Errorf("%w: %q ist keine bandbreitenangabe (erwartet etwa 50MB oder 400Mbit)",
ErrInvalidRate, limitText)
}
return int64(numericValue), nil
}