Files
yellowjacket/.planning/milestones/v1.2-phases/15-schema-migration-write-safety/15-02-PLAN.md
T
yonlu 2256f8f329 chore: complete v1.2 Tag Editing milestone
Archive v1.2 milestone: ROADMAP + REQUIREMENTS + phases to milestones/.
Evolve PROJECT.md with v1.2 validated requirements and key decisions.
Update RETROSPECTIVE.md with v1.2 lessons and cross-milestone trends.
Clean STATE.md for next milestone.
2026-03-18 14:07:28 -04:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
15-schema-migration-write-safety 02 execute 1
backend/fileutil/atomicwrite.go
backend/fileutil/atomicwrite_test.go
true
SCHEMA-02
WRITE-05
truths artifacts key_links
AtomicWrite writes to a temp file then renames to target — original file is never in a half-written state
Temp files use .yj-tmp suffix
Cross-filesystem writes are rejected with a clear error
Original file permissions are preserved on the new file
Orphaned .yj-tmp files for the target path are cleaned up before writing
Unit tests verify all behaviors including crash simulation
path provides exports min_lines
backend/fileutil/atomicwrite.go General-purpose atomic file write utility
AtomicWrite
40
path provides min_lines
backend/fileutil/atomicwrite_test.go Comprehensive tests for atomic write 80
from to via pattern
backend/fileutil/atomicwrite.go os.Rename atomic rename from temp to target os.Rename
from to via pattern
backend/fileutil/atomicwrite.go os.Stat preserve original file permissions os.Stat
Create a general-purpose atomic file write utility package for safe file modifications.

Purpose: Phase 16+ tag writers need to modify audio files without risk of corruption. This utility handles write-to-temp-then-rename, permission preservation, cross-directory rejection, and orphan cleanup. Callback API pattern: AtomicWrite(targetPath, func(tempFile *os.File) error).

Output: New backend/fileutil package with AtomicWrite function and comprehensive tests.

<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/15-schema-migration-write-safety/15-CONTEXT.md

From backend/events/cmd/genevents/main.go:

// writeAtomic writes data to a temporary file in the same directory as path,
// then renames it into place for atomic replacement.
func writeAtomic(path, data string) error {
    dir := filepath.Dir(path)
    tmp, err := os.CreateTemp(dir, ".genevents-*.tmp")
    if err != nil {
        return err
    }
    tmpName := tmp.Name()
    if _, err := tmp.WriteString(data); err != nil {
        _ = tmp.Close()
        _ = os.Remove(tmpName)
        return err
    }
    if err := tmp.Close(); err != nil {
        _ = os.Remove(tmpName)
        return err
    }
    return os.Rename(tmpName, path)
}
Task 1: Create backend/fileutil package with AtomicWrite backend/fileutil/atomicwrite.go Create a new package `backend/fileutil` with an `AtomicWrite` function.

Package doc comment:

// Package fileutil provides file system utilities for safe file operations.
package fileutil

API:

func AtomicWrite(targetPath string, fn func(tmp *os.File) error) error

Implementation requirements (from CONTEXT.md locked decisions):

  1. Temp file naming: Use targetPath + ".yj-tmp" as the temp file path. Do NOT use os.CreateTemp with random patterns — the deterministic suffix enables orphan cleanup. Example: writing to song.mp3 creates song.mp3.yj-tmp.

  2. Orphan cleanup: Before creating the temp file, check if targetPath + ".yj-tmp" already exists (orphan from a previous crash). If it does, remove it. If removal fails (permissions, file lock), log at debug level and continue — don't block the operation. Accept an optional *slog.Logger parameter or use a package-level approach. Per CONTEXT.md: "If an orphaned temp file can't be deleted (permissions, file lock), log a warning and continue."

    Decision: Use a slog.Logger parameter for consistency with codebase conventions. Signature becomes:

    func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error
    
  3. Cross-filesystem rejection: Before the rename, verify the temp file and target are on the same filesystem. The simplest approach: since the temp file is created in the same directory as the target (using filepath.Dir(targetPath)), same-directory guarantees same filesystem. But the function should still guard against the caller passing a targetPath that resolves across mount points. Use an explicit check: call os.Stat on the parent directory and compare device IDs. Actually — the simpler and more robust approach per CONTEXT.md: "Cross-filesystem writes rejected with a clear error — no fallback to copy-then-delete." Since the temp file is always in the same dir as target, os.Rename will fail if the directory itself is somehow cross-device. Let os.Rename return the error naturally, and wrap it with a clear message mentioning cross-filesystem. Define a sentinel error:

    var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported")
    

    After os.Rename fails, check if the error is syscall.EXDEV (cross-device link) and wrap with ErrCrossDevice. For other rename errors, wrap normally.

  4. Permission preservation: Before writing, os.Stat(targetPath) to get the current file mode. If the target exists, apply os.Chmod(tmpPath, mode) before the rename. If the target doesn't exist, use 0644 as default (per CONTEXT.md).

  5. Cleanup on error: If the callback fn returns an error, or if Close() fails, or if Chmod fails — remove the temp file before returning. Always clean up on failure.

  6. Implementation flow:

    a. Clean orphaned .yj-tmp file (if exists)
    b. Stat target for permissions (os.Stat, handle not-exist)
    c. Create temp file (os.Create on targetPath + ".yj-tmp")
    d. Call fn(tmpFile) — caller writes data
    e. Sync temp file (tmpFile.Sync() for durability)
    f. Close temp file
    g. Chmod temp file to match target permissions
    h. Rename temp file to target (atomic)
    i. On any error in d-h: remove temp file, return wrapped error
    

Sentinel errors:

var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported")

What to avoid:

  • Do NOT use os.CreateTemp with random patterns — the deterministic .yj-tmp suffix is a locked decision
  • Do NOT use io.Copy fallback for cross-device — rejection is the correct behavior per CONTEXT.md
  • Do NOT make this audio-file-specific — it's a general-purpose utility per CONTEXT.md ("not audio-file-specific") cd /mnt/vault/dev/golang/yellowjacket && go vet -tags webkit2_41 ./backend/fileutil/ && go build -tags webkit2_41 ./backend/fileutil/
    • backend/fileutil/atomicwrite.go exists with exported AtomicWrite function
    • ErrCrossDevice sentinel error exported
    • Package compiles without errors
    • Function signature: AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error
Task 2: Add comprehensive tests for AtomicWrite backend/fileutil/atomicwrite_test.go Create `backend/fileutil/atomicwrite_test.go` with comprehensive table-driven tests.

Test cases to implement:

  1. TestAtomicWrite_Success — Happy path:

    • Create a target file with known content and specific permissions (e.g., 0o755)
    • Call AtomicWrite to overwrite with new content
    • Verify: target has new content, permissions preserved, no .yj-tmp file remains
  2. TestAtomicWrite_NewFile — Target doesn't exist:

    • Call AtomicWrite on a path that doesn't exist yet
    • Verify: file created with new content, permissions are 0644, no .yj-tmp remains
  3. TestAtomicWrite_CallbackError — Callback returns error:

    • Call AtomicWrite with a callback that returns an error after partial write
    • Verify: original file content is unchanged, no .yj-tmp file remains, error propagated
  4. TestAtomicWrite_OrphanCleanup — Crash simulation:

    • Create a .yj-tmp orphan file manually (simulating previous crash)
    • Call AtomicWrite on the same target
    • Verify: orphan was cleaned up, new write succeeded, target has correct content
  5. TestAtomicWrite_CrossDirectoryRejection — Different directory:

    • This test verifies the behavior when rename would cross filesystems
    • Since we can't easily create cross-filesystem scenarios in CI, test that the temp file is always created in the same directory as the target:
      • Call AtomicWrite on a file in t.TempDir()/subdir/file.txt
      • During the callback, verify the temp file exists at t.TempDir()/subdir/file.txt.yj-tmp
      • This confirms the temp file is always same-dir, making cross-device impossible in normal use
  6. TestAtomicWrite_PermissionPreservation — Table-driven with different modes:

    • Test with 0o644, 0o755, 0o600
    • Verify each mode is preserved after atomic write
  7. TestAtomicWrite_SyncAndClose — Verify file is properly synced:

    • Write substantial data (e.g., 1MB)
    • Verify target file size matches expected after AtomicWrite

All tests must follow codebase patterns:

  • package fileutil (internal test, same package)
  • t.Parallel() at top level and in subtests
  • t.TempDir() for all file operations
  • t.Fatalf for setup failures, t.Errorf for assertion failures
  • No assertion libraries — raw comparisons
  • //nolint:mnd for magic numbers in test data where needed

Logger for tests: Use slog.Default() — tests don't need special log handling. cd /mnt/vault/dev/golang/yellowjacket && go test -tags webkit2_41 -v -race -count=1 -timeout 30s ./backend/fileutil/ - All 7 test functions pass - Tests verify: successful write, new file creation, callback error rollback, orphan cleanup, same-dir temp file, permission preservation, proper sync - Race detector passes (no concurrency issues) - make test passes (full test suite including new tests) - make lint passes (no lint violations in new code)

1. `go test -tags webkit2_41 -v -race -count=1 ./backend/fileutil/` — all tests pass 2. `make test` — full test suite passes 3. `make lint` — no lint violations 4. `go vet -tags webkit2_41 ./backend/fileutil/` — clean 5. Grep verification: `grep -rn '\.yj-tmp' backend/fileutil/` confirms .yj-tmp suffix usage 6. Grep verification: `grep -n 'ErrCrossDevice' backend/fileutil/atomicwrite.go` confirms sentinel exported

<success_criteria>

  • backend/fileutil/ package exists with AtomicWrite function and ErrCrossDevice sentinel
  • AtomicWrite uses .yj-tmp suffix, callback API, permission preservation, orphan cleanup, cross-device rejection
  • 7 test functions covering success, new file, callback error, orphan cleanup, same-dir, permissions, sync
  • All tests pass with race detector
  • Full make test and make lint pass </success_criteria>
After completion, create `.planning/phases/15-schema-migration-write-safety/15-02-SUMMARY.md`