-
Notifications
You must be signed in to change notification settings - Fork 0
fix(atomicfile): ccp-sbp.5 — adopt atomicfile for durable-state writes #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import ( | |
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/dkoosis/atomicfile" | ||
| "github.com/dkoosis/strand/internal/bd" | ||
| "github.com/dkoosis/strand/internal/bdcounts" | ||
| ) | ||
|
|
@@ -208,6 +209,13 @@ func readRows(path string) map[string]Row { | |
| // file + rename, so a concurrent reader never sees a half-written file. The meta rides | ||
| // under bdcounts.MetaKey — a reserved key no repo path collides with — so keyed readers | ||
| // (bdcounts.Reader.Lookup, the status line's `.[$repo]`) are untouched by its presence. | ||
| // | ||
| // NOTE (RMW race, not fixed by atomic write): two concurrent refreshes (the | ||
| // launchd --all run and a manual `strand counts`) each read-compute-write the | ||
| // whole rows set independently. atomicfile.WriteFile stops either write from | ||
| // being torn, but it does not serialize the two writers — whichever finishes | ||
| // last wins wholesale, silently dropping the other's rows. Follow-up: a lock | ||
| // around the refresh, or a merge-on-write like writeState's. | ||
| func writeRowsAtomic(path string, rows map[string]Row, meta bdcounts.Meta) error { | ||
| out := make(map[string]any, len(rows)+1) | ||
| for k, v := range rows { | ||
|
|
@@ -218,24 +226,12 @@ func writeRowsAtomic(path string, rows map[string]Row, meta bdcounts.Meta) error | |
| if err != nil { | ||
| return fmt.Errorf("counts: marshal: %w", err) | ||
| } | ||
| tmp := tmpPath(path) | ||
| if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { | ||
| if err := atomicfile.WriteFile(path, append(data, '\n'), 0o600); err != nil { | ||
| return fmt.Errorf("counts: write: %w", err) | ||
| } | ||
|
Comment on lines
+229
to
231
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
go doc github.com/dkoosis/atomicfile.WithMkdirAll
for file in \
internal/counts/refresh.go \
internal/registry/registry.go \
internal/strandmd/strandmd.go
do
rg -n -C 3 'os\.MkdirAll|atomicfile\.WriteFile' "$file"
doneRepository: dkoosis/strand Length of output: 4004 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- module declaration ---'
cat go.mod | sed -n '1,120p'
printf '%s\n' '--- relevant implementations and callers ---'
for file in internal/counts/refresh.go internal/registry/registry.go internal/strandmd/strandmd.go; do
printf '\n--- %s ---\n' "$file"
sed -n '100,140p' "$file" 2>/dev/null || true
sed -n '280,315p' "$file" 2>/dev/null || true
done
printf '%s\n' '--- atomicfile source at the referenced revision ---'
curl -fsSL https://raw.githubusercontent.com/dkoosis/atomicfile/9091c28d4820/atomicfile.go | sed -n '1,260p'Repository: dkoosis/strand Length of output: 14038 Use
📍 Affects 3 files
🤖 Prompt for AI AgentsSource: MCP tools |
||
| if err := os.Rename(tmp, path); err != nil { | ||
| return fmt.Errorf("counts: rename: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // tmpPath is a per-process temp name for the atomic write. The pid suffix keeps two | ||
| // concurrent refreshes (the launchd --all and a manual `strand counts`) off one | ||
| // shared tmp, where they would clobber each other's write and race the rename to a | ||
| // spurious ENOENT — the same guard the shell got from `$OUT.$$`. | ||
| func tmpPath(path string) string { | ||
| return fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) | ||
| } | ||
|
|
||
| // repoState is one repo's carry-forward bookkeeping between refresh runs: the | ||
| // last-observed change key (changeKey — the Dolt store mtime, or last-touched when | ||
| // no store), plus the st-3p8 pending bit (a change-triggered derive schedules exactly | ||
|
|
@@ -283,6 +279,12 @@ func readState(path string) map[string]repoState { | |
| // changed-mode run would then find no prior mtime for those repos and cold-recompute | ||
| // them all (st-dd9). Reading the prior state and overlaying keeps the untouched repos' | ||
| // gate entries intact. | ||
| // | ||
| // NOTE (RMW race, not fixed by atomic write): the read-merge-write above is not | ||
| // serialized against a concurrent writer — two refreshes racing this function can | ||
| // each read the same prior state, merge their own entries on top, and whichever | ||
| // atomicfile.WriteFile finishes last wins wholesale, silently dropping the other's | ||
| // merged entries. Same follow-up as writeRowsAtomic. | ||
| func writeState(path string, state map[string]repoState) error { | ||
| merged := readState(path) | ||
| maps.Copy(merged, state) | ||
|
|
@@ -295,13 +297,9 @@ func writeState(path string, state map[string]repoState) error { | |
| } | ||
| fmt.Fprintf(&b, "%s\t%d\t%s\n", root, st.mtime, pendingField) | ||
| } | ||
| tmp := tmpPath(path) | ||
| if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil { | ||
| if err := atomicfile.WriteFile(path, []byte(b.String()), 0o600); err != nil { | ||
| return fmt.Errorf("counts: write state: %w", err) | ||
| } | ||
| if err := os.Rename(tmp, path); err != nil { | ||
| return fmt.Errorf("counts: rename state: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: dkoosis/strand
Length of output: 50370
🏁 Script executed:
Repository: dkoosis/strand
Length of output: 43225
🏁 Script executed:
Repository: dkoosis/strand
Length of output: 50370
🏁 Script executed:
Repository: dkoosis/strand
Length of output: 50370
🏁 Script executed:
Repository: dkoosis/strand
Length of output: 13000
Serialize the complete
refreshread-modify-write sequence.Concurrent
counts.Runprocesses can read the same base, then atomically replacecounts.jsonorcounts-mtimesand silently discard the other update.atomicfile.WriteFileprevents torn writes, not lost updates. Add a cross-process lock around the entirerefresh, or implement re-read and merge retry logic.🤖 Prompt for AI Agents
Source: MCP tools