Share change filtering between the durable and test transaction paths, collapse repeated precondition handling, unify journal sequence parsing, and split the subprocess worker test file. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
230 lines
5.7 KiB
Go
230 lines
5.7 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
)
|
|
|
|
type renameFunc func(source, destination string) error
|
|
type replaceWithBackupFunc func(target, replacement, backup string) error
|
|
|
|
type fileChange struct {
|
|
filePath string
|
|
label string
|
|
original []byte
|
|
originalInfo os.FileInfo
|
|
originalID string
|
|
originalMode os.FileMode
|
|
existed bool
|
|
next []byte
|
|
mode os.FileMode
|
|
remove bool
|
|
}
|
|
|
|
type stagedChange struct {
|
|
change fileChange
|
|
temporaryPath string
|
|
temporaryInfo os.FileInfo
|
|
rollbackPath string
|
|
originalCaptured bool
|
|
committedInfo os.FileInfo
|
|
committed bool
|
|
}
|
|
|
|
type transactionFileOps struct {
|
|
moveNoReplace renameFunc
|
|
compensateNoReplace renameFunc
|
|
replaceWithBackup replaceWithBackupFunc
|
|
production bool
|
|
}
|
|
|
|
func newTransactionFileOps(rename renameFunc) transactionFileOps {
|
|
if rename == nil {
|
|
rename = atomicRenameNoReplace
|
|
return transactionFileOps{
|
|
moveNoReplace: rename,
|
|
compensateNoReplace: rename,
|
|
replaceWithBackup: atomicReplaceWithBackup,
|
|
production: true,
|
|
}
|
|
}
|
|
return transactionFileOps{
|
|
moveNoReplace: rename,
|
|
compensateNoReplace: atomicRenameNoReplace,
|
|
}
|
|
}
|
|
|
|
func sameManagedFileMode(current, expected os.FileMode) bool {
|
|
return runtime.GOOS == "windows" || current.Perm() == expected.Perm()
|
|
}
|
|
|
|
func readOptionalFile(path, label string) ([]byte, bool, error) {
|
|
snapshot, err := readManagedFile(path, label, maxManagedSourceBytes)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if snapshot == nil {
|
|
return nil, false, nil
|
|
}
|
|
return append([]byte(nil), snapshot.contents...), true, nil
|
|
}
|
|
|
|
func prepareFileChange(filePath, label string, next []byte, mode os.FileMode) (*fileChange, error) {
|
|
original, existed, err := readOptionalFile(filePath, label)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return prepareSourceChange(filePath, label, original, existed, next, mode, false)
|
|
}
|
|
|
|
func prepareSourceChange(
|
|
filePath, label string,
|
|
original []byte,
|
|
existed bool,
|
|
next []byte,
|
|
mode os.FileMode,
|
|
remove bool,
|
|
) (*fileChange, error) {
|
|
if !existed {
|
|
original = nil
|
|
}
|
|
if !remove && int64(len(next)) > maxManagedSourceBytes {
|
|
return nil, fmt.Errorf(
|
|
"%s exceeds %d bytes: %s",
|
|
label,
|
|
maxManagedSourceBytes,
|
|
filePath,
|
|
)
|
|
}
|
|
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentExists := snapshot != nil
|
|
if currentExists != existed ||
|
|
(currentExists && !bytes.Equal(snapshot.contents, original)) {
|
|
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
|
|
}
|
|
if existed && !remove && bytes.Equal(original, next) &&
|
|
sameManagedFileMode(snapshot.mode, mode) {
|
|
return nil, nil
|
|
}
|
|
if !existed && (remove || next == nil) {
|
|
return nil, nil
|
|
}
|
|
originalMode := mode
|
|
var originalInfo os.FileInfo
|
|
if existed {
|
|
originalInfo = snapshot.info
|
|
originalMode = snapshot.mode
|
|
}
|
|
return &fileChange{
|
|
filePath: filePath, label: label, original: append([]byte(nil), original...),
|
|
originalInfo: originalInfo, originalID: snapshotIdentity(snapshot),
|
|
originalMode: originalMode, existed: existed,
|
|
next: append([]byte(nil), next...),
|
|
mode: mode, remove: remove,
|
|
}, nil
|
|
}
|
|
|
|
func snapshotIdentity(snapshot *managedFileSnapshot) string {
|
|
if snapshot == nil {
|
|
return ""
|
|
}
|
|
return snapshot.identity
|
|
}
|
|
|
|
func writeChanges(
|
|
changes []*fileChange,
|
|
label string,
|
|
rename renameFunc,
|
|
preconditions ...filePrecondition,
|
|
) error {
|
|
return writeChangesWithFileOps(
|
|
changes,
|
|
label,
|
|
newTransactionFileOps(rename),
|
|
preconditions...,
|
|
)
|
|
}
|
|
|
|
func writeChangesWithFileOps(
|
|
changes []*fileChange,
|
|
label string,
|
|
ops transactionFileOps,
|
|
preconditions ...filePrecondition,
|
|
) error {
|
|
if ops.production {
|
|
return writeDurableChanges(changes, label, ops, preconditions...)
|
|
}
|
|
if ops.moveNoReplace == nil {
|
|
return fmt.Errorf("%s is missing atomic file operations", label)
|
|
}
|
|
filtered, err := filterDurableChanges(changes, label)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
checkPreconditions := func() error {
|
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
|
return fmt.Errorf("%s: %w", label, err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := checkPreconditions(); err != nil {
|
|
return err
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
staged := make([]stagedChange, 0, len(filtered))
|
|
abortStaging := func(cause error) error {
|
|
return joinTransactionFailure(cause, cleanupStaging(staged), "cleanup failed")
|
|
}
|
|
for _, change := range filtered {
|
|
if err := checkPreconditions(); err != nil {
|
|
return abortStaging(err)
|
|
}
|
|
item, err := stageChange(change)
|
|
if err != nil {
|
|
return abortStaging(fmt.Errorf("%s: %w", label, err))
|
|
}
|
|
staged = append(staged, item)
|
|
}
|
|
if err := checkPreconditions(); err != nil {
|
|
return abortStaging(err)
|
|
}
|
|
recoverStaged := func(cause error) error {
|
|
failures := rollbackChanges(staged, ops)
|
|
failures = append(failures, cleanupStaging(staged)...)
|
|
return joinTransactionFailure(cause, failures, "recovery failed")
|
|
}
|
|
for index := range staged {
|
|
if err := checkPreconditions(); err != nil {
|
|
return recoverStaged(err)
|
|
}
|
|
if err := commitChange(&staged[index], ops); err != nil {
|
|
return recoverStaged(fmt.Errorf("%s: %w", label, err))
|
|
}
|
|
}
|
|
if err := checkPreconditions(); err != nil {
|
|
return recoverStaged(err)
|
|
}
|
|
if err := assertCommittedChanges(staged); err != nil {
|
|
return recoverStaged(fmt.Errorf("%s: %w", label, err))
|
|
}
|
|
if failures := cleanupStaging(staged); len(failures) > 0 {
|
|
return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), failures, "cleanup failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func joinTransactionFailure(cause error, related []error, label string) error {
|
|
if len(related) == 0 {
|
|
return cause
|
|
}
|
|
return errors.Join(cause, fmt.Errorf("%s: %w", label, errors.Join(related...)))
|
|
}
|