This commit is contained in:
2026-06-10 20:27:44 +02:00
parent 42b5c6247d
commit 6150498a9e
9 changed files with 223 additions and 120 deletions
+33 -18
View File
@@ -36,13 +36,14 @@ func DefaultFolder(dataDir string) string {
return filepath.Join(dataDir, "backups")
}
// Run executes one backup pass: checkpoint WALcopy the database file
// → optionally zip → rotate. Returns the path of the file that was
// written so the caller can surface it to the UI.
// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
// zip → rotate. Returns the path of the file that was written so the caller
// can surface it to the UI.
//
// dbConn is used to issue the WAL checkpoint so the on-disk file is
// self-consistent before we copy it. It's the same *sql.DB the app uses;
// SQLite tolerates concurrent reads during the copy.
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
// statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
@@ -56,24 +57,38 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
// Flush WAL into the main file so a raw copy is a complete database.
// TRUNCATE removes the -wal file's contents after checkpointing.
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
return "", fmt.Errorf("wal_checkpoint: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place.
tmp := filepath.Join(folder, base+".vacuum.tmp")
_ = os.Remove(tmp)
if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("vacuum into: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".db.zip")
if err := copyZipped(dbPath, dstPath); err != nil {
// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".db")
if err := copyFile(dbPath, dstPath); err != nil {
return "", err
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
// Rename can fail across filesystems — fall back to a copy.
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
@@ -105,9 +120,9 @@ func copyFile(src, dst string) error {
}
// copyZipped writes a single-entry deflate zip containing the database.
// The inner filename is just the base of the source so unzip restores
// "hamlog.db" wherever the user extracts.
func copyZipped(src, dst string) error {
// innerName is the entry name inside the zip (the live DB's base name) so
// unzip restores e.g. "opslog.db" wherever the user extracts.
func copyZipped(src, dst, innerName string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
@@ -119,7 +134,7 @@ func copyZipped(src, dst string) error {
return fmt.Errorf("create dest: %w", err)
}
zw := zip.NewWriter(out)
w, err := zw.Create(filepath.Base(src))
w, err := zw.Create(innerName)
if err != nil {
zw.Close()
out.Close()
+1 -1
View File
@@ -317,7 +317,7 @@ func (s *session) run() {
// Returns the moment we marked the link "connected" (zero if dial failed)
// and the error that ended the session (nil if stopCh).
func (s *session) runOnce() (time.Time, error) {
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
+5 -1
View File
@@ -17,7 +17,11 @@ var migrationsFS embed.FS
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", path)
// Escape only the two characters a path could contain that the DSN would
// otherwise read as its query/fragment delimiters. Windows separators
// (\\ and the drive ':') are left intact — url.PathEscape would mangle them.
safePath := strings.NewReplacer("?", "%3F", "#", "%23").Replace(path)
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", safePath)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
+3
View File
@@ -84,6 +84,9 @@ func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error)
stationByID[s.ID] = len(stations)
stations = append(stations, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(stations) == 0 {
return stations, nil
}
+1 -1
View File
@@ -108,7 +108,7 @@ func parseAzimuth(s string) (int, bool) {
}
func (c *Client) send(payload string) error {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port)) // IPv6-safe
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
if err != nil {
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)