63 lines
2.7 KiB
Go
63 lines
2.7 KiB
Go
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/urpi/go-bananas/internal/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Snapshot writes a consistent copy of the database to dest.
|
||
|
|
//
|
||
|
|
// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database
|
||
|
|
// while a write is in flight yields a torn copy: the header can say one thing and
|
||
|
|
// the pages say another, and the result may open successfully while being
|
||
|
|
// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result
|
||
|
|
// is consistent even while the service is serving traffic, and it does not block
|
||
|
|
// writers for the length of the copy.
|
||
|
|
//
|
||
|
|
// Two further properties come from the statement itself rather than from code
|
||
|
|
// here: it refuses to overwrite an existing file, so a stale snapshot can never be
|
||
|
|
// mistaken for a fresh one, and it writes to a temporary name and renames on
|
||
|
|
// success, so a snapshot that fails halfway is not left looking complete.
|
||
|
|
//
|
||
|
|
// The connection must not be read-only: VACUUM INTO needs to read the schema and
|
||
|
|
// pages through a normal connection even though it only ever writes to the new
|
||
|
|
// file.
|
||
|
|
func (s *Store) Snapshot(ctx context.Context, dest string) error {
|
||
|
|
trimmed := strings.TrimSpace(dest)
|
||
|
|
if trimmed == "" {
|
||
|
|
return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid)
|
||
|
|
}
|
||
|
|
if dir := filepath.Dir(trimmed); dir != "" && dir != "." {
|
||
|
|
// Created by the process rather than by SQLite, which has no mkdir. The
|
||
|
|
// snapshot directory normally already exists (the Helm chart's subPath mount
|
||
|
|
// creates it), so this is a convenience for a manual invocation.
|
||
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
|
|
return fmt.Errorf("create snapshot directory %s: %w", dir, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// VACUUM INTO takes an SQL string literal rather than a bound parameter on some
|
||
|
|
// SQLite builds, so the path is escaped into one here. Doubling the quote is
|
||
|
|
// the documented escape, and rejecting an embedded NUL keeps the literal
|
||
|
|
// well-formed regardless.
|
||
|
|
if strings.ContainsRune(trimmed, 0) {
|
||
|
|
return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid)
|
||
|
|
}
|
||
|
|
literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'"
|
||
|
|
|
||
|
|
//nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a
|
||
|
|
// bound parameter on some SQLite builds. The literal is escaped above by doubling
|
||
|
|
// any single quote, which is the documented escape, and NUL bytes are rejected so
|
||
|
|
// the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers
|
||
|
|
// a path containing a quote.
|
||
|
|
if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil {
|
||
|
|
return fmt.Errorf("write snapshot to %s: %w", trimmed, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|