Files
restic-manager/internal/store/snapshots.go
T
steve f9718e6077
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m32s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
Make snapshot projections self-diagnosing
2026-08-22 11:00:46 +01:00

129 lines
3.7 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Snapshot mirrors the snapshots projection table.
type Snapshot struct {
ID string
HostID string
ShortID string
Time time.Time
Hostname string
Paths []string
Tags []string
SizeBytes int64
FileCount int64
RefreshedAt time.Time
}
// ReplaceHostSnapshots atomically replaces the snapshot projection for
// one host. Snapshots are reported by the agent in full after each
// successful backup, so we treat the message as the new source of
// truth and delete-then-insert under one transaction.
//
// snapshot_count on the host row is updated in the same tx so the
// dashboard's per-host count is always consistent with the snapshot
// list the host detail page renders.
func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps []Snapshot, when time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin snapshots tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`DELETE FROM snapshots WHERE host_id = ?`, hostID); err != nil {
return fmt.Errorf("store: clear snapshots for host: %w", err)
}
if len(snaps) > 0 {
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO snapshots (
id, host_id, short_id, time, hostname, paths, tags,
size_bytes, file_count, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("store: prepare snapshot insert: %w", err)
}
defer func() { _ = stmt.Close() }()
refreshed := when.UTC().Format(time.RFC3339Nano)
for _, snap := range snaps {
paths, _ := json.Marshal(snap.Paths)
tags, _ := json.Marshal(snap.Tags)
if _, err := stmt.ExecContext(ctx,
snap.ID, hostID, snap.ShortID,
snap.Time.UTC().Format(time.RFC3339Nano),
snap.Hostname, string(paths), string(tags),
snap.SizeBytes, snap.FileCount, refreshed,
); err != nil {
return fmt.Errorf("store: insert snapshot %s: %w", snap.ID, err)
}
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE hosts SET snapshot_count = ?, snapshot_refreshed_at = ? WHERE id = ?`,
len(snaps), when.UTC().Format(time.RFC3339Nano), hostID); err != nil {
return fmt.Errorf("store: update host snapshot_count: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit snapshots: %w", err)
}
return nil
}
// ListSnapshotsByHost returns the cached snapshot list for a host,
// most-recent first. Empty slice is a normal "no snapshots yet" case.
func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snapshot, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, host_id, short_id, time, hostname, paths, tags,
size_bytes, file_count, refreshed_at
FROM snapshots
WHERE host_id = ?
ORDER BY time DESC`, hostID)
if err != nil {
return nil, fmt.Errorf("store: list snapshots: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Snapshot
for rows.Next() {
snap, err := scanSnapshotRow(rows)
if err != nil {
return nil, err
}
out = append(out, *snap)
}
return out, rows.Err()
}
func scanSnapshotRow(r *sql.Rows) (*Snapshot, error) {
var (
snap Snapshot
t, refresh string
paths, tags string
)
if err := r.Scan(&snap.ID, &snap.HostID, &snap.ShortID,
&t, &snap.Hostname, &paths, &tags,
&snap.SizeBytes, &snap.FileCount, &refresh); err != nil {
return nil, fmt.Errorf("store: scan snapshot: %w", err)
}
snap.Time, _ = time.Parse(time.RFC3339Nano, t)
snap.RefreshedAt, _ = time.Parse(time.RFC3339Nano, refresh)
if paths != "" {
_ = json.Unmarshal([]byte(paths), &snap.Paths)
}
if tags != "" {
_ = json.Unmarshal([]byte(tags), &snap.Tags)
}
return &snap, nil
}