Skip to content

fix(st-k6z): serialize the counts refresh read-modify-write across processes - #109

Merged
dkoosis merged 2 commits into
mainfrom
fix/st-k6z-refresh-lock
Aug 12, 2026
Merged

fix(st-k6z): serialize the counts refresh read-modify-write across processes#109
dkoosis merged 2 commits into
mainfrom
fix/st-k6z-refresh-lock

Conversation

@dkoosis

@dkoosis dkoosis commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes the lost-update race CodeRabbit raised on #108 and deferred as out of scope.

The bug

atomicfile.WriteFile (#108) stopped torn writes, not lost updates. Three writers race the same two files:

  • launchd's strand counts --all
  • a manual strand counts [dir...]
  • the server's per-write strand counts <repo> spawn (defaultRefreshCounts, fired on every bead write)

Each reads counts.json + counts-mtimes, computes, then atomically replaces both. Whichever finishes last wins wholesale, silently dropping the others' rows. writeState's merge-on-write narrows its own window but does not close it — the merge read is itself unserialized.

The fix

An advisory flock on <cacheDir>/.refresh.lock, held for the whole refresh — opened before the first read, since the reads are the R of the read-modify-write. refresh now does MkdirAll + lock; the old body is refreshLocked.

Taken non-blocking on a 50ms poll rather than a blocking LOCK_EX, so both ctx cancellation and a 2-minute wait cap stay honored: a wedged holder makes the run fail loudly instead of accumulating blocked refreshers behind launchd's every-minute fire. flock is kernel-side and per-fd, so a crashed holder releases automatically (no stale-marker problem) and it serializes goroutines in one process as well as separate processes.

The two NOTE (RMW race…) comments #108 left behind are retired — replaced by a pointer to where the serialization now lives.

Tests

  • TestRefreshSerializesConcurrentRuns — two refreshes over disjoint repos, overlapping in time via a gated source; both repos must land in counts.json and counts-mtimes. Mutation-checked: reverting refresh to call refreshLocked directly fails it (no counts.json row for repo-b).
  • TestRefreshLockTimesOutOnWedgedHolder — a held lock makes a second withLock return an error without running fn (cancelled ctx stands in for the 2-minute deadline).

Gate

make check green: go vet, golangci-lint 0 issues, go test -race -count=1 ./... all packages. pack-drift skipped — upstream unreachable, pre-existing.

bead: st-k6z

@codex review

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when multiple count refreshes run concurrently.
    • Prevented overlapping refreshes from overwriting cached count data.
    • Added timeout and cancellation handling when a refresh lock cannot be acquired.
  • Tests

    • Added coverage for concurrent refreshes and stalled lock holders.

…ocesses

atomicfile stopped torn writes; it did not stop lost updates. The launchd
--all run, a manual `strand counts`, and the server's per-write
`strand counts <repo>` spawns each read counts.json + counts-mtimes,
compute, and replace both files — last writer wins wholesale.

Take an advisory flock on the cache dir for the whole refresh, opened
before the first read. Non-blocking poll so ctx cancellation and a
2-minute wait cap stay honored: a wedged holder fails the run loudly
instead of piling up blocked refreshers behind launchd's every-minute
fire.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@dkoosis, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e3b95827-c58c-4f1b-8801-d14eb401cd90

📥 Commits

Reviewing files that changed from the base of the PR and between 0e987c2 and da1af90.

📒 Files selected for processing (4)
  • internal/counts/lock.go
  • internal/counts/lock_test.go
  • internal/counts/refresh.go
  • internal/server/server.go
📝 Walkthrough

Walkthrough

The change adds per-cache advisory locking for counts refreshes. Refresh now serializes its read-modify-write workflow, handles cancellation and timeout, and includes concurrency regression tests.

Changes

Counts refresh locking

Layer / File(s) Summary
Advisory lock acquisition
internal/counts/lock.go
withLock uses an exclusive .refresh.lock file lock. Acquisition polls without blocking, respects context cancellation, and times out after two minutes.
Locked refresh workflow and validation
internal/counts/refresh.go, internal/counts/lock_test.go
refresh locks the cache directory before running refreshLocked. Tests verify concurrent refresh preservation and cancellation while another holder retains the lock. Documentation describes writer serialization and atomic reader protection.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: ⚪ Minimal · up to 0e987

The PR serializes the counts refresh read-modify-write and adds focused concurrency and timeout tests. Remaining follow-up is limited to strengthening one test assertion and confirming platform support, with no actionable merge-blocking risk beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Refresh
  participant WithLock
  participant RefreshLocked
  participant CountsFiles

  Refresh->>WithLock: Acquire cache-directory lock
  WithLock->>RefreshLocked: Run read-modify-write workflow
  RefreshLocked->>CountsFiles: Write counts rows and merged state
  CountsFiles-->>RefreshLocked: Persisted data
  RefreshLocked-->>WithLock: Return refresh result
  WithLock-->>Refresh: Release lock
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: serializing counts refresh read-modify-write operations across processes.
Description check ✅ Passed The description directly explains the lost-update race, flock-based fix, locking behavior, tests, and validation results.
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.

🧹 Nitpick comments (1)
internal/counts/lock_test.go (1)

119-125: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the error identity, not only non-nil.

The current check passes for any error, including an unrelated failure such as a lock-file open error. Assert errors.Is(err, context.Canceled) so the test proves the cancellation path. Note also that lockWait and errLockBusy stay uncovered; consider making lockWait injectable if you want that path tested.

♻️ Proposed stronger assertion
-	if err == nil {
-		t.Fatal("withLock returned nil while the lock was held")
-	}
+	if !errors.Is(err, context.Canceled) {
+		t.Fatalf("withLock error = %v, want context.Canceled", err)
+	}

Add the import:

 import (
 	"context"
+	"errors"
 	"os"
🤖 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/lock_test.go` around lines 119 - 125, Update the withLock
test around the existing err == nil assertion to verify errors.Is(err,
context.Canceled), importing the errors package as needed. Keep the callback
assertion and ensure the test specifically validates cancellation rather than
merely any non-nil error; leave lockWait injection out of scope unless required
for this assertion.
🤖 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.

Nitpick comments:
In `@internal/counts/lock_test.go`:
- Around line 119-125: Update the withLock test around the existing err == nil
assertion to verify errors.Is(err, context.Canceled), importing the errors
package as needed. Keep the callback assertion and ensure the test specifically
validates cancellation rather than merely any non-nil error; leave lockWait
injection out of scope unless required for this assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2498531e-5dcd-49c2-bbeb-97197981d00e

📥 Commits

Reviewing files that changed from the base of the PR and between 0ad4c4b and 0e987c2.

📒 Files selected for processing (3)
  • internal/counts/lock.go
  • internal/counts/lock_test.go
  • internal/counts/refresh.go

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e987c20ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/counts/lock.go
Comment on lines +71 to +74
select {
case <-ctx.Done():
return fmt.Errorf("counts: waiting for refresh lock: %w", ctx.Err())
case <-time.After(lockPoll):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate the server deadline into lock acquisition

When another refresh holds .refresh.lock for more than 10 seconds, the production post-write path cannot cancel this wait: defaultRefreshCounts discards the context from goBackground(10*time.Second, ...), and counts.Run invokes refresh with context.Background(). Consequently, this new cancellation branch never observes either the server timeout or Server.Stop, so every queued post-write goroutine may wait for the full two-minute lockWait, while Stop blocks on bgWG; expose and use a context-aware Run path here.

Useful? React with 👍 / 👎.

Codex review: defaultRefreshCounts discarded goBackground's context and
counts.Run used context.Background(), so a slow lock holder could keep
every queued post-write goroutine alive past its 10s timeout and pin
Server.Stop's bgWG wait behind lockWait.

Add counts.RunContext for in-process callers that own a lifetime; Run
keeps context.Background() as the CLI root. Cover the lockWait and
cancellation paths with named-error assertions (CodeRabbit).
@dkoosis

dkoosis commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Assessed both reviews: 1 accepted (P2, real), 1 accepted (nit). Pushed as da1af90.

@chatgpt-codex-connector — propagate the server deadline into lock acquisition. Correct, and the sharpest catch here. defaultRefreshCounts took func(_ context.Context) and counts.Run rooted at context.Background(), so neither goBackground's 10s timeout nor Server.Stop could reach the new wait — a slow holder would park every queued post-write goroutine for the full lockWait and Stop would block on bgWG behind it. My change turned a previously-harmless discarded context into a shutdown stall.

Fixed by adding counts.RunContext(ctx, args, version) for in-process callers that own a lifetime; Run stays the CLI root that supplies context.Background() (keeping the forbidigo carve-out at the one place it belongs). defaultRefreshCounts now passes goBackground's ctx through.

@coderabbitai — assert the error identity, not just non-nil. Fair; the old assertion would have passed on an unrelated lock-file open error. Split into two named-error tests, which also closes the coverage gap you flagged: TestRefreshLockGivesUpOnWedgedHolder asserts errors.Is(err, errLockBusy) with lockWait shrunk to 150ms (now a var for exactly that), and TestRefreshLockHonorsContextCancel asserts errors.Is(err, context.Canceled) — the path Codex's finding is about.

One correction to my own PR body: the server's post-write refresh calls counts.Run in-process, it does not spawn a strand counts subprocess. The race is unchanged (in-process refreshes race each other and launchd's subprocess alike) and it's why the ctx fix matters.

make check green: vet, golangci-lint 0 issues, go test -race -count=1 ./....

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@dkoosis Thanks for the detailed follow-up. The named error assertions address the finding and distinguish lock contention from unrelated lock-file failures. The separate cancellation test also covers the server-deadline path.

🐇 ✅


✏️ Learnings added
Learnt from: dkoosis
URL: https://github.com/dkoosis/strand/pull/109

Timestamp: 2026-08-12T23:11:50.886Z
Learning: In the Go counts package, server post-write refreshes run in-process rather than by spawning a `strand counts` subprocess. In-process callers with a lifetime context must call `counts.RunContext(ctx, args, version)` so lock acquisition can observe server deadlines and shutdown cancellation; `counts.Run` remains the CLI entry point that roots execution at `context.Background()`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Verified that commit da1af90 already addresses the P2 finding by adding counts.RunContext, while retaining Run as the CLI entry point using a background context. internal/counts/refresh.goL51-L61
  • Confirmed that the server now propagates goBackground’s timeout/cancellation context into the counts refresh, preventing lock acquisition from delaying shutdown until the full lock timeout. internal/server/server.goL257-L270
  • Confirmed the strengthened tests separately assert errLockBusy for a wedged holder and context.Canceled for cancellation. internal/counts/lock_test.goL121-L156
  • No additional changes were required. The working tree remains clean at da1af90; therefore, no new commit or pull request was created.

Testing

  • go test -count=1 ./internal/counts ./internal/server
  • ⚠️ make checkgo vet ./... passed, but the lint stage could not run because the installed golangci-lint was built with Go 1.24 while the project targets Go 1.26.5.
  • ⚠️ bd prime — unavailable because bd is not installed in the environment.
  • git status --short --branch — clean working tree on branch work.

View task →

@dkoosis
dkoosis merged commit 5343751 into main Aug 12, 2026
2 checks passed
@dkoosis
dkoosis deleted the fix/st-k6z-refresh-lock branch August 12, 2026 23:15
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