Adopt the shared .golangci.yml and fix the code to it (closes #6)
check / check (push) Successful in 4m30s
check / check (push) Successful in 4m30s
Vendor .golangci.yml byte-identical from sneak/prompts at cc440118 and move the Dockerfile lint phase to golangci-lint v2.14.0 by the digest REPO_POLICIES.md names. Fix the code to that config with flags, help text, output files, SQL and the order of steps unchanged; long functions are split into named steps. Judgement call: the extraction transaction is now rolled back on every early return; the old deferred rollback missed most failures and could dereference a nil transaction. Wording change: the reversed-range error reads "--from is after --to (--from X, --to Y)", since err113 requires a wrapped sentinel. Ten //nolint directives (gosec, mnd, unqueryvet), each with its reason. Model: opus-5-5
This commit is contained in:
+174
-87
@@ -9,21 +9,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var errEmptySource = errors.New("source file is empty")
|
||||
|
||||
// cleanup removes a temporary file, logging a warning if removal fails so
|
||||
// that leaked scratch files are surfaced rather than silently ignored. A
|
||||
// missing file is not an error.
|
||||
func cleanup(path string) {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
err := os.Remove(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to remove temporary file", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run writes a compressed SQL dump of each day in targetDates, taken from
|
||||
// the latest daily snapshot, skipping days that already have one or have
|
||||
// no posts. With no dates it does the day before the snapshot date.
|
||||
func Run(targetDates []time.Time) error {
|
||||
snapshotDir, snapshotDate, err := FindLatestDailySnapshot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding latest snapshot: %w", err)
|
||||
}
|
||||
slog.Info("found latest daily snapshot", "dir", snapshotDir, "snapshot_date", snapshotDate.Format("2006-01-02"))
|
||||
|
||||
slog.Info("found latest daily snapshot", "dir", snapshotDir,
|
||||
"snapshot_date", snapshotDate.Format("2006-01-02"))
|
||||
|
||||
if len(targetDates) == 0 {
|
||||
targetDates = []time.Time{snapshotDate.AddDate(0, 0, -1)}
|
||||
@@ -34,10 +42,13 @@ func Run(targetDates []time.Time) error {
|
||||
"last", targetDates[len(targetDates)-1].Format("2006-01-02"))
|
||||
|
||||
// Check disk space
|
||||
if err := CheckFreeSpace(TmpBase, MinTmpFreeBytes, "tmpBase"); err != nil {
|
||||
err = CheckFreeSpace(TmpBase, MinTmpFreeBytes, "tmpBase")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckFreeSpace(DailiesBase, MinDailiesFreeBytes, "dailiesBase"); err != nil {
|
||||
|
||||
err = CheckFreeSpace(DailiesBase, MinDailiesFreeBytes, "dailiesBase")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -46,14 +57,53 @@ func Run(targetDates []time.Time) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp directory in %s: %w", TmpBase, err)
|
||||
}
|
||||
|
||||
slog.Info("created temp directory", "path", tmpDir)
|
||||
|
||||
defer func() {
|
||||
slog.Info("cleaning up temp directory", "path", tmpDir)
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
slog.Error("failed to remove temp directory", "path", tmpDir, "error", err)
|
||||
|
||||
rerr := os.RemoveAll(tmpDir)
|
||||
if rerr != nil {
|
||||
slog.Error("failed to remove temp directory",
|
||||
"path", tmpDir, "error", rerr)
|
||||
}
|
||||
}()
|
||||
|
||||
dstDB, err := copySnapshotFiles(snapshotDir, tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process each day completely before moving to the next. This ensures
|
||||
// we don't have multiple SQLite operations competing for the same
|
||||
// source database.
|
||||
processed := 0
|
||||
skipped := 0
|
||||
|
||||
for _, targetDay := range targetDates {
|
||||
written, err := processDay(tmpDir, dstDB, targetDay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if written {
|
||||
processed++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("run summary", "processed", processed, "skipped", skipped,
|
||||
"total", len(targetDates))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copySnapshotFiles copies the database, its WAL and, if present, its SHM
|
||||
// file from snapshotDir into tmpDir, and returns the copied database's
|
||||
// path.
|
||||
func copySnapshotFiles(snapshotDir, tmpDir string) (string, error) {
|
||||
// Copy database files from snapshot to temp
|
||||
srcDB := filepath.Join(snapshotDir, DBFilename)
|
||||
srcWAL := filepath.Join(snapshotDir, WALFilename)
|
||||
@@ -65,100 +115,137 @@ func Run(targetDates []time.Time) error {
|
||||
for _, f := range []string{srcDB, srcWAL} {
|
||||
info, err := os.Stat(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source file missing: %s: %w", f, err)
|
||||
return "", fmt.Errorf("source file missing: %s: %w", f, err)
|
||||
}
|
||||
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("source file is empty: %s", f)
|
||||
return "", fmt.Errorf("%w: %s", errEmptySource, f)
|
||||
}
|
||||
|
||||
slog.Info("source file", "path", f, "size_bytes", info.Size())
|
||||
}
|
||||
|
||||
if err := CopyFile(srcDB, dstDB); err != nil {
|
||||
return fmt.Errorf("copying database: %w", err)
|
||||
}
|
||||
if err := CopyFile(srcWAL, dstWAL); err != nil {
|
||||
return fmt.Errorf("copying WAL: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(srcSHM); err == nil {
|
||||
if err := CopyFile(srcSHM, dstSHM); err != nil {
|
||||
return fmt.Errorf("copying SHM: %w", err)
|
||||
}
|
||||
err := CopyFile(srcDB, dstDB)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("copying database: %w", err)
|
||||
}
|
||||
|
||||
// Process each day completely before moving to the next
|
||||
// This ensures we don't have multiple SQLite operations competing for the same source database
|
||||
processed := 0
|
||||
skipped := 0
|
||||
err = CopyFile(srcWAL, dstWAL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("copying WAL: %w", err)
|
||||
}
|
||||
|
||||
for _, targetDay := range targetDates {
|
||||
dayStr := targetDay.Format("2006-01-02")
|
||||
slog.Info("processing day", "date", dayStr)
|
||||
|
||||
// Check if output already exists
|
||||
outputDir := filepath.Join(DailiesBase, targetDay.Format("2006-01"))
|
||||
outputFinal := filepath.Join(outputDir, dayStr+".sql.zst")
|
||||
if _, err := os.Stat(outputFinal); err == nil {
|
||||
slog.Info("output already exists, skipping", "path", outputFinal)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract target day into a per-day database
|
||||
extractedDB := filepath.Join(tmpDir, "extracted-"+dayStr+".db")
|
||||
slog.Info("extracting target day", "src", dstDB, "dst", extractedDB)
|
||||
if err := ExtractDay(dstDB, extractedDB, targetDay); err != nil {
|
||||
if errors.Is(err, ErrNoPosts) {
|
||||
slog.Warn("no posts found, skipping day", "date", dayStr)
|
||||
cleanup(extractedDB)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("extracting day %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
// Dump to SQL and compress
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
cleanup(extractedDB)
|
||||
return fmt.Errorf("creating output directory %s: %w", outputDir, err)
|
||||
}
|
||||
|
||||
outputTmp := filepath.Join(outputDir, "."+dayStr+".sql.zst.tmp")
|
||||
|
||||
slog.Info("dumping and compressing", "tmp_output", outputTmp)
|
||||
if err := DumpAndCompress(extractedDB, outputTmp); err != nil {
|
||||
cleanup(outputTmp)
|
||||
cleanup(extractedDB)
|
||||
return fmt.Errorf("dump and compress for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
slog.Info("verifying compressed output")
|
||||
if err := VerifyOutput(outputTmp); err != nil {
|
||||
cleanup(outputTmp)
|
||||
cleanup(extractedDB)
|
||||
return fmt.Errorf("verification failed for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
// Atomic rename to final path
|
||||
slog.Info("renaming to final output", "from", outputTmp, "to", outputFinal)
|
||||
if err := os.Rename(outputTmp, outputFinal); err != nil {
|
||||
cleanup(outputTmp)
|
||||
cleanup(extractedDB)
|
||||
return fmt.Errorf("atomic rename for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(outputFinal)
|
||||
_, err = os.Stat(srcSHM)
|
||||
if err == nil {
|
||||
err = CopyFile(srcSHM, dstSHM)
|
||||
if err != nil {
|
||||
cleanup(extractedDB)
|
||||
return fmt.Errorf("stat final output: %w", err)
|
||||
return "", fmt.Errorf("copying SHM: %w", err)
|
||||
}
|
||||
slog.Info("day completed", "date", dayStr, "path", outputFinal, "size_bytes", info.Size())
|
||||
|
||||
// Remove extracted DB to reclaim space immediately
|
||||
cleanup(extractedDB)
|
||||
processed++
|
||||
}
|
||||
|
||||
slog.Info("run summary", "processed", processed, "skipped", skipped, "total", len(targetDates))
|
||||
return dstDB, nil
|
||||
}
|
||||
|
||||
// processDay extracts one day from the copied database at dstDB and
|
||||
// publishes its compressed dump. It returns false, with no error, for a
|
||||
// day it skips: one whose output already exists or that has no posts.
|
||||
func processDay(tmpDir, dstDB string, targetDay time.Time) (bool, error) {
|
||||
dayStr := targetDay.Format("2006-01-02")
|
||||
|
||||
slog.Info("processing day", "date", dayStr)
|
||||
|
||||
// Check if output already exists
|
||||
outputDir := filepath.Join(DailiesBase, targetDay.Format("2006-01"))
|
||||
outputFinal := filepath.Join(outputDir, dayStr+".sql.zst")
|
||||
|
||||
_, err := os.Stat(outputFinal)
|
||||
if err == nil {
|
||||
slog.Info("output already exists, skipping", "path", outputFinal)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Extract target day into a per-day database
|
||||
extractedDB := filepath.Join(tmpDir, "extracted-"+dayStr+".db")
|
||||
|
||||
slog.Info("extracting target day", "src", dstDB, "dst", extractedDB)
|
||||
|
||||
err = ExtractDay(dstDB, extractedDB, targetDay)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoPosts) {
|
||||
slog.Warn("no posts found, skipping day", "date", dayStr)
|
||||
cleanup(extractedDB)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("extracting day %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
// Dump to SQL and compress
|
||||
//nolint:gosec,mnd // world-readable on purpose: the dailies tree is published
|
||||
err = os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
cleanup(extractedDB)
|
||||
|
||||
return false, fmt.Errorf("creating output directory %s: %w", outputDir, err)
|
||||
}
|
||||
|
||||
outputTmp := filepath.Join(outputDir, "."+dayStr+".sql.zst.tmp")
|
||||
|
||||
slog.Info("dumping and compressing", "tmp_output", outputTmp)
|
||||
|
||||
err = DumpAndCompress(extractedDB, outputTmp)
|
||||
if err != nil {
|
||||
cleanup(outputTmp)
|
||||
cleanup(extractedDB)
|
||||
|
||||
return false, fmt.Errorf("dump and compress for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
slog.Info("verifying compressed output")
|
||||
|
||||
err = VerifyOutput(outputTmp)
|
||||
if err != nil {
|
||||
cleanup(outputTmp)
|
||||
cleanup(extractedDB)
|
||||
|
||||
return false, fmt.Errorf("verification failed for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
err = publishOutput(outputTmp, outputFinal, dayStr)
|
||||
|
||||
// Remove extracted DB to reclaim space immediately
|
||||
cleanup(extractedDB)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// publishOutput renames the verified temporary output to its final path
|
||||
// and logs the finished day. The rename is atomic, so the final path never
|
||||
// holds a partial file.
|
||||
func publishOutput(outputTmp, outputFinal, dayStr string) error {
|
||||
// Atomic rename to final path
|
||||
slog.Info("renaming to final output", "from", outputTmp, "to", outputFinal)
|
||||
|
||||
err := os.Rename(outputTmp, outputFinal)
|
||||
if err != nil {
|
||||
cleanup(outputTmp)
|
||||
|
||||
return fmt.Errorf("atomic rename for %s: %w", dayStr, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(outputFinal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat final output: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("day completed", "date", dayStr, "path", outputFinal,
|
||||
"size_bytes", info.Size())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user