textmachine/backend/internal/pipeline/artifact.go

60 lines
2.8 KiB
Go

package pipeline
import (
"fmt"
"os"
"path/filepath"
)
// artifact.go: the shared write discipline of the engine's READ-OUT FILES — the sidecars beside the
// project DB that another process (the platform, D39.81/D39.85) reads while this one runs: the
// chapter/chunk manifest (row 100), the machine bank-stop table (row 101), the bank export (row 125).
//
// They have one property in common that the older sidecars did not have to care about: they are read
// CONCURRENTLY with the run that writes them. A plain os.WriteFile truncates first, so a reader that
// opens the file in that window gets a valid path, zero bytes and a parse error indistinguishable from
// a corrupt artifact. Write-then-rename makes every read see either the previous document or the next
// one, whole — the same guarantee the store gets from its transactions, at file granularity.
// writeFileAtomic replaces path with data through a temp file in the SAME directory + rename. Same
// directory is load-bearing: rename is atomic only within a filesystem, and the project dir is where the
// reader is looking anyway. Every RETURNED failure removes its temp file, so a failed write leaves the
// previous document in place and nothing beside it; a kill -9 mid-write can still leave one dot-prefixed
// temp file, which is inert (no reader looks at it) and is not worth a startup sweep.
func writeFileAtomic(path string, data []byte) error {
dir, base := filepath.Dir(path), filepath.Base(path)
f, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("pipeline: create temp for %s: %w", path, err)
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("pipeline: write %s: %w", tmp, err)
}
// Sync before the rename: without it the rename's metadata can reach disk ahead of the bytes, so a host
// crash can leave a present-but-truncated document under the real name. The manifest would survive that
// (a parse failure degrades to the re-chunk), but the bank export and the stop table have no such
// validation — a consumer would read a short document as a complete one.
if err := f.Sync(); err != nil {
f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("pipeline: sync %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("pipeline: close %s: %w", tmp, err)
}
// 0o644 like the other sidecars: CreateTemp makes 0o600, and an artifact another process reads must
// not depend on that process running as the same user.
if err := os.Chmod(tmp, 0o644); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("pipeline: chmod %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("pipeline: replace %s: %w", path, err)
}
return nil
}