Skip to content

fix(atomicfile): ccp-sbp.5 — adopt atomicfile for durable-state writes - #108

Merged
dkoosis merged 1 commit into
mainfrom
fix/ccp-sbp.5-strand
Aug 12, 2026
Merged

fix(atomicfile): ccp-sbp.5 — adopt atomicfile for durable-state writes#108
dkoosis merged 1 commit into
mainfrom
fix/ccp-sbp.5-strand

Conversation

@dkoosis

@dkoosis dkoosis commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Converts every non-test raw os.WriteFile durable-state site to github.com/dkoosis/atomicfile.WriteFile, and retires the hand-rolled tmp+rename helper.

Converted (3 sites)

  • internal/strandmd/strandmd.go readOrInit — shipped default STRAND.md, written once on first init.
  • internal/registry/registry.go Registry.saveLockedrepos.json, single-writer under the registry's mutex.
  • internal/counts/refresh.go writeRowsAtomic + writeStatecounts.json and the per-repo state file. Both are multi-writer (launchd --all vs a manual strand counts can race the same path); atomicfile removes the torn-write risk but not the read-modify-write race, so each carries a NOTE comment flagging the RMW gap as a follow-up (a lock, or merge-on-write like writeState already does for its own field).

Retired

internal/counts/refresh.go's tmpPath() + manual os.Rename dance (used by both writeRowsAtomic and writeState) — atomicfile.WriteFile does temp+fsync+rename itself, including the parent-dir fsync the old helper never had.

Skipped (test-only, 13 sites)

All remaining os.WriteFile hits are test fixture setup (writing input files for a test to read), not durable app state — left as-is: strandmd/northstar_test.go, strandmd/strandmd_test.go, bdcounts/bdcounts_test.go, suggest/prompts_test.go (×3), jtbd/jtbd_test.go, bd/store_test.go, bd/write_test.go (×2), strand/strand_test.go, registry/registry_test.go, server/northstar_test.go, server/pulse_source_test.go (×3), counts/refresh_test.go (×2), server/server_test.go.

go.mod

Adds github.com/dkoosis/atomicfile + its renameio/v2 and x/sys transitive deps via go mod tidy. The go directive (1.26.4) already meets atomicfile's floor — no bump needed.

Gate

make check green: go vet, golangci-lint (0 issues), go test -race (all packages pass). pack-drift step skipped — upstream unreachable, pre-existing network condition unrelated to this change.

bead: ccp-sbp.5

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when saving counts, state, registry data, and default documentation by using atomic file writes.
    • Reduced the risk of partially written or corrupted files if an interruption occurs during saving.

Converts three raw os.WriteFile durable-state sites to
github.com/dkoosis/atomicfile.WriteFile (write-temp + fsync + rename),
and retires the hand-rolled tmp+rename helper in internal/counts.

Converted:
- internal/strandmd/strandmd.go: readOrInit — shipped default STRAND.md,
  written once on first init (parent dir already MkdirAll'd).
- internal/registry/registry.go: Registry.saveLocked — repos.json,
  single-writer under r's mutex (parent dir already MkdirAll'd).
- internal/counts/refresh.go: writeRowsAtomic + writeState — counts.json
  and the per-repo state file. Both are multi-writer (launchd --all vs a
  manual `strand counts` can race); atomicfile removes the torn-write
  risk but NOT the read-modify-write race, so each now carries a NOTE
  comment flagging the RMW gap for a follow-up (a lock, or merge-on-write
  like writeState already does for its own field).

Retired: internal/counts/refresh.go's tmpPath() + manual os.Rename
dance in both writeRowsAtomic and writeState — atomicfile.WriteFile
does the temp+fsync+rename itself, including the parent-dir fsync the
old helper never had.

Skipped (test-only os.WriteFile calls, not durable-state — left as-is):
strandmd/northstar_test.go, strandmd/strandmd_test.go,
bdcounts/bdcounts_test.go, suggest/prompts_test.go, jtbd/jtbd_test.go,
bd/store_test.go, bd/write_test.go, strand/strand_test.go,
registry/registry_test.go, server/northstar_test.go,
server/pulse_source_test.go, counts/refresh_test.go,
server/server_test.go.

go.mod: adds github.com/dkoosis/atomicfile + its renameio/v2 + x/sys
transitive deps via go mod tidy. go directive (1.26.4) already met
atomicfile's floor — no bump needed.

Gate: make check green (vet, lint, race test suite; pack-drift skipped,
upstream unreachable, pre-existing network condition).
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds atomic file persistence for counts, state, registry, and default STRAND.md files. It replaces temporary-file and direct-write logic with atomicfile.WriteFile and documents remaining concurrent read-modify-write races.

Changes

Atomic persistence

Layer / File(s) Summary
Atomic refresh output writes
go.mod, internal/counts/refresh.go
The refresh path uses atomicfile.WriteFile for counts and state files. The code documents that concurrent refreshes can still cause last-writer-wins data loss.
Atomic registry and default-file writes
internal/registry/registry.go, internal/strandmd/strandmd.go
Registry persistence and default STRAND.md initialization use atomicfile.WriteFile while retaining existing modes and error handling.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description accurately explains the atomic durable-state writes, retired helper, skipped test fixtures, dependencies, and validation results.
Title check ✅ Passed The title clearly identifies the main change: adopting atomicfile for durable-state writes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/counts/refresh.go`:
- Around line 229-231: Update the first-write paths to pass
atomicfile.WithMkdirAll(0o755) to atomicfile.WriteFile: apply this to the
counts.json and state writes in internal/counts/refresh.go (229-231), the
repos.json write in internal/registry/registry.go (305), and the default
STRAND.md write in internal/strandmd/strandmd.go (131-132). Remove the separate
os.MkdirAll calls replaced by this option.
- Around line 212-218: Serialize the entire read-modify-write sequence in
refresh, including reading existing counts state, computing updates, and writing
counts.json/counts-mtimes. Add a cross-process lock around refresh (or
equivalent re-read-and-merge retry logic) so concurrent counts.Run processes
cannot overwrite each other’s rows; retain atomicfile.WriteFile for tear-free
writes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ae059a8-249a-4e1a-bc26-b46329d46c1f

📥 Commits

Reviewing files that changed from the base of the PR and between 92831f7 and 40af76d.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod
  • internal/counts/refresh.go
  • internal/registry/registry.go
  • internal/strandmd/strandmd.go

Comment on lines +212 to +218
//
// 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.

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'readRows|writeRowsAtomic|readState|writeState|refresh|flock|Lock' internal/counts
rg -n -C 3 'launchd|strand counts|counts\.Run|refresh\(' --glob '*.go' .

Repository: dkoosis/strand

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- refresh sequence ---'
sed -n '92,138p' internal/counts/refresh.go

printf '%s\n' '--- row writer ---'
sed -n '195,234p' internal/counts/refresh.go

printf '%s\n' '--- state writer ---'
sed -n '274,315p' internal/counts/refresh.go

printf '%s\n' '--- production call sites and background scheduling ---'
sed -n '120,145p' internal/server/server.go
sed -n '240,265p' internal/server/server.go
rg -n -C 4 'counts\.Run|defaultRefreshCounts|refreshCounts|goBackground' --glob '*.go' .

printf '%s\n' '--- atomicfile references and module metadata ---'
rg -n -C 3 'github.com/dkoosis/atomicfile|WithMkdirAll|func WriteFile|WriteFile' --glob '*.go' --glob 'go.mod' --glob 'go.sum' .

Repository: dkoosis/strand

Length of output: 43225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- external refresh trigger ---'
rg -n -C 4 'counts( --all)?|StartInterval|bd-counts|launchd' \
  --glob '!internal/counts/refresh.go' --glob '!internal/counts/refresh_test.go' .

printf '%s\n' '--- pinned atomicfile implementation ---'
curl -fsSL https://raw.githubusercontent.com/dkoosis/atomicfile/9091c28d4820/atomicfile.go |
  sed -n '1,240p'

printf '%s\n' '--- standalone lost-update model ---'
python3 - <<'PY'
import json

base_rows = {"repo-a": "old-a", "repo-b": "old-b"}
a_rows = dict(base_rows)
b_rows = dict(base_rows)
a_rows["repo-a"] = "new-a"
b_rows["repo-b"] = "new-b"

# Both writers use atomic replacement. B completes last, so the file is valid
# JSON but contains neither writer's complete result.
final_rows = b_rows
assert json.loads(json.dumps(final_rows)) == {
    "repo-a": "old-a",
    "repo-b": "new-b",
}
assert final_rows["repo-a"] != a_rows["repo-a"]
print("last-writer-wins:", json.dumps(final_rows, sort_keys=True))
print("repo-a update lost while the resulting file remains valid JSON")
PY

Repository: dkoosis/strand

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- external refresh trigger ---'
rg -n -C 4 'counts( --all)?|StartInterval|bd-counts|launchd' \
  --glob '!internal/counts/refresh.go' --glob '!internal/counts/refresh_test.go' .

printf '%s\n' '--- pinned atomicfile implementation ---'
curl -fsSL https://raw.githubusercontent.com/dkoosis/atomicfile/9091c28d4820/atomicfile.go |
  sed -n '1,240p'

printf '%s\n' '--- standalone lost-update model ---'
python3 - <<'PY'
import json

base_rows = {"repo-a": "old-a", "repo-b": "old-b"}
a_rows = dict(base_rows)
b_rows = dict(base_rows)
a_rows["repo-a"] = "new-a"
b_rows["repo-b"] = "new-b"

final_rows = b_rows
assert json.loads(json.dumps(final_rows)) == {
    "repo-a": "old-a",
    "repo-b": "new-b",
}
assert final_rows["repo-a"] != a_rows["repo-a"]
print("last-writer-wins:", json.dumps(final_rows, sort_keys=True))
print("repo-a update lost while the resulting file remains valid JSON")
PY

Repository: dkoosis/strand

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for log in \
  /tmp/coderabbit-shell-logs/shell-output-Lk08ky \
  /tmp/coderabbit-shell-logs/shell-output-wYN1HR
do
  echo "--- $log: atomicfile and model results ---"
  rg -n -A 80 -B 5 'pinned atomicfile|func WriteFile|atomic replacement|last-writer-wins|repo-a update lost|standalone lost-update' "$log" || true
done

Repository: dkoosis/strand

Length of output: 13000


Serialize the complete refresh read-modify-write sequence.

Concurrent counts.Run processes can read the same base, then atomically replace counts.json or counts-mtimes and silently discard the other update. atomicfile.WriteFile prevents torn writes, not lost updates. Add a cross-process lock around the entire refresh, or implement re-read and merge retry logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/counts/refresh.go` around lines 212 - 218, Serialize the entire
read-modify-write sequence in refresh, including reading existing counts state,
computing updates, and writing counts.json/counts-mtimes. Add a cross-process
lock around refresh (or equivalent re-read-and-merge retry logic) so concurrent
counts.Run processes cannot overwrite each other’s rows; retain
atomicfile.WriteFile for tear-free writes.

Source: MCP tools

Comment on lines +229 to 231
if err := atomicfile.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("counts: write: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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"
done

Repository: 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 atomicfile.WithMkdirAll(0o755) for all first writes.

os.MkdirAll does not durably persist newly created directories. Apply the option to the counts.json and state writes, repos.json, and default STRAND.md writes. Remove the separate os.MkdirAll calls where the option replaces them.

📍 Affects 3 files
  • internal/counts/refresh.go#L229-L231 (this comment)
  • internal/registry/registry.go#L305-L305
  • internal/strandmd/strandmd.go#L131-L132
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/counts/refresh.go` around lines 229 - 231, Update the first-write
paths to pass atomicfile.WithMkdirAll(0o755) to atomicfile.WriteFile: apply this
to the counts.json and state writes in internal/counts/refresh.go (229-231), the
repos.json write in internal/registry/registry.go (305), and the default
STRAND.md write in internal/strandmd/strandmd.go (131-132). Remove the separate
os.MkdirAll calls replaced by this option.

Source: MCP tools

@dkoosis

dkoosis commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Assessed CodeRabbit's 2 findings: 1 accepted as follow-up bead, 1 rejected.

Rejected — WithMkdirAll on the first-write paths (refresh.go:229/231, registry.go:305, strandmd.go:131). All three sites already os.MkdirAll the parent before writing (refresh.go:123, registry.go:298, strandmd.go:128). Swapping to the option is a wash: same syscalls, and it would move directory creation from an explicit line to a write-call flag. No behavior change on offer.

Accepted, out of scope — serialize the refresh read-modify-write. Real: atomicfile stops torn writes, not lost updates, and two concurrent refreshes can still discard each other's rows. That's a pre-existing race this PR documents rather than introduces (see the two NOTE (RMW race…) comments). Filed as st-k6z with the flock-vs-merge-retry options recorded. Fixing it here would mean a new cross-process lock, well past 'adopt atomicfile'.

Gate green on the branch: go build ./..., go test ./... all packages pass. Merging.

@dkoosis
dkoosis merged commit 0ad4c4b into main Aug 12, 2026
2 checks passed
@dkoosis
dkoosis deleted the fix/ccp-sbp.5-strand branch August 12, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant