All checks were successful
check / check (push) Successful in 1m42s
Handle every error return flagged by golangci-lint's errcheck rather than discarding it: - run.go: add a cleanup() helper that logs a warning (and ignores ErrNotExist) when removing a temp file fails, so leaked scratch files are surfaced; use it for all best-effort removals. - copy.go / extract.go: log a warning on deferred Close() failures for the source file, destination DB, and result-set rows. - extract.go: on the rollback path, ignore the benign sql.ErrTxDone (already committed) and log any other rollback failure. - verify.go: add killCat() which ignores os.ErrProcessDone (zstdcat already exited via SIGPIPE) and logs any unexpected kill failure. make check is clean (0 lint issues, tests pass).
165 lines
5.1 KiB
Go
165 lines
5.1 KiB
Go
package bsdaily
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// 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) {
|
|
slog.Warn("failed to remove temporary file", "path", path, "error", err)
|
|
}
|
|
}
|
|
|
|
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"))
|
|
|
|
if len(targetDates) == 0 {
|
|
targetDates = []time.Time{snapshotDate.AddDate(0, 0, -1)}
|
|
}
|
|
|
|
slog.Info("target days for extraction", "count", len(targetDates),
|
|
"first", targetDates[0].Format("2006-01-02"),
|
|
"last", targetDates[len(targetDates)-1].Format("2006-01-02"))
|
|
|
|
// Check disk space
|
|
if err := CheckFreeSpace(TmpBase, MinTmpFreeBytes, "tmpBase"); err != nil {
|
|
return err
|
|
}
|
|
if err := CheckFreeSpace(DailiesBase, MinDailiesFreeBytes, "dailiesBase"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create temp directory
|
|
tmpDir, err := os.MkdirTemp(TmpBase, "bsarchivesegment-*")
|
|
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)
|
|
}
|
|
}()
|
|
|
|
// Copy database files from snapshot to temp
|
|
srcDB := filepath.Join(snapshotDir, DBFilename)
|
|
srcWAL := filepath.Join(snapshotDir, WALFilename)
|
|
srcSHM := filepath.Join(snapshotDir, SHMFilename)
|
|
dstDB := filepath.Join(tmpDir, DBFilename)
|
|
dstWAL := filepath.Join(tmpDir, WALFilename)
|
|
dstSHM := filepath.Join(tmpDir, SHMFilename)
|
|
|
|
for _, f := range []string{srcDB, srcWAL} {
|
|
info, err := os.Stat(f)
|
|
if err != nil {
|
|
return fmt.Errorf("source file missing: %s: %w", f, err)
|
|
}
|
|
if info.Size() == 0 {
|
|
return fmt.Errorf("source file is empty: %s", 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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
if err != nil {
|
|
cleanup(extractedDB)
|
|
return fmt.Errorf("stat final output: %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 nil
|
|
}
|