-
Notifications
You must be signed in to change notification settings - Fork 0
fix(st-k6z): serialize the counts refresh read-modify-write across processes #109
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
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package counts | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "syscall" | ||
| "time" | ||
| ) | ||
|
|
||
| // lockName is the advisory lock file inside the counts cache dir. It carries no | ||
| // content — flock's kernel-side state is the lock, so a crashed holder releases it | ||
| // automatically when its fd closes (unlike a hand-rolled O_EXCL marker, which would | ||
| // strand the next run behind a stale file). | ||
| const lockName = ".refresh.lock" | ||
|
|
||
| // lockWait bounds how long a refresh waits for the holder. A wedged process must not | ||
| // let launchd's every-minute fire pile up blocked refreshers forever; past the wait we | ||
| // give up loudly instead of hanging. | ||
| const lockWait = 2 * time.Minute | ||
|
|
||
| // lockPoll is the retry interval while another process holds the lock. | ||
| const lockPoll = 50 * time.Millisecond | ||
|
|
||
| // errLockBusy is returned when the wait elapses with another refresh still holding the | ||
| // lock — a wedged or very slow holder, not a transient overlap. | ||
| var errLockBusy = errors.New("counts: refresh already running") | ||
|
|
||
| // withLock runs fn while holding an exclusive cross-process lock on dir, so the whole | ||
| // read-compute-write of counts.json + counts-mtimes is serialized (st-k6z). Atomic | ||
| // writes alone are not enough: two refreshes (launchd's `--all` run, a manual | ||
| // `strand counts`, and the server's per-write `strand counts <repo>` spawns) each read | ||
| // the same base, compute, and replace the file — the last writer wins wholesale and | ||
| // silently drops the other's rows. The lock closes that window; the atomic write still | ||
| // protects concurrent *readers* mid-rename. | ||
| // | ||
| // The lock is advisory and per-fd, so it serializes goroutines in one process as well | ||
| // as separate processes. It is taken non-blocking on a poll loop rather than with a | ||
| // blocking LOCK_EX so ctx cancellation and lockWait both stay honored. | ||
| func withLock(ctx context.Context, dir string, fn func() error) error { | ||
| f, err := os.OpenFile(filepath.Join(dir, lockName), os.O_CREATE|os.O_RDWR, 0o600) | ||
| if err != nil { | ||
| return fmt.Errorf("counts: open lock: %w", err) | ||
| } | ||
| defer f.Close() | ||
|
|
||
| if err := acquire(ctx, f); err != nil { | ||
| return err | ||
| } | ||
| defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck // closing the fd releases the lock regardless | ||
|
|
||
| return fn() | ||
| } | ||
|
|
||
| // acquire polls for the exclusive lock until it lands, ctx ends, or lockWait elapses. | ||
| func acquire(ctx context.Context, f *os.File) error { | ||
| deadline := time.Now().Add(lockWait) | ||
| for { | ||
| err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) | ||
| if err == nil { | ||
| return nil | ||
| } | ||
| if !errors.Is(err, syscall.EWOULDBLOCK) { | ||
| return fmt.Errorf("counts: lock: %w", err) | ||
| } | ||
| if time.Now().After(deadline) { | ||
| return fmt.Errorf("%w (waited %s for %s)", errLockBusy, lockWait, f.Name()) | ||
| } | ||
| select { | ||
| case <-ctx.Done(): | ||
| return fmt.Errorf("counts: waiting for refresh lock: %w", ctx.Err()) | ||
| case <-time.After(lockPoll): | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package counts | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/dkoosis/strand/internal/bd" | ||
| "github.com/dkoosis/strand/internal/bdcounts" | ||
| ) | ||
|
|
||
| // gatedSource is a source whose first read parks until released, so a test can hold | ||
| // one refresh mid-compute while a second one starts against the same cache dir. | ||
| type gatedSource struct { | ||
| fakeSource | ||
| entered chan struct{} // closed when the read starts | ||
| release chan struct{} // the read returns once this is closed | ||
| once sync.Once | ||
| } | ||
|
|
||
| func (g *gatedSource) List(ctx context.Context, opts bd.ListOpts) ([]bd.Issue, error) { | ||
| g.once.Do(func() { | ||
| close(g.entered) | ||
| <-g.release | ||
| }) | ||
| return g.fakeSource.List(ctx, opts) | ||
| } | ||
|
|
||
| // TestRefreshSerializesConcurrentRuns is the st-k6z regression: two refreshes over | ||
| // disjoint repos, overlapping in time, must both land their rows in counts.json and | ||
| // their gate entries in counts-mtimes. Atomic writes alone do not give this — before | ||
| // the cache-dir lock, the second run read the base while the first was still computing | ||
| // and then replaced the file, silently dropping the first run's repo. | ||
| func TestRefreshSerializesConcurrentRuns(t *testing.T) { | ||
| projects := t.TempDir() | ||
| cache := t.TempDir() | ||
| a := mkRepo(t, projects, "repo-a") | ||
| b := mkRepo(t, projects, "repo-b") | ||
|
|
||
| gate := &gatedSource{ | ||
| fakeSource: fakeSource{issues: []bd.Issue{{ID: "x", Status: bd.StatusOpen}}}, | ||
| entered: make(chan struct{}), | ||
| release: make(chan struct{}), | ||
| } | ||
| slow := config{ | ||
| cacheDir: cache, projects: projects, mode: modeExplicit, targets: []string{a}, | ||
| newSource: func(string) source { return gate }, | ||
| } | ||
| fast := config{ | ||
| cacheDir: cache, projects: projects, mode: modeExplicit, targets: []string{b}, | ||
| newSource: func(string) source { return oneOpenBead() }, | ||
| } | ||
|
|
||
| errs := make(chan error, 2) | ||
| go func() { errs <- refresh(context.Background(), &slow) }() | ||
|
|
||
| select { | ||
| case <-gate.entered: | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("slow refresh never reached its first read") | ||
| } | ||
| go func() { errs <- refresh(context.Background(), &fast) }() | ||
| // Give the second run time to reach the lock (and, unlocked, to read the base and | ||
| // race ahead) before the first is allowed to finish. | ||
| time.Sleep(100 * time.Millisecond) | ||
| close(gate.release) | ||
|
|
||
| for range 2 { | ||
| select { | ||
| case err := <-errs: | ||
| if err != nil { | ||
| t.Fatalf("refresh: %v", err) | ||
| } | ||
| case <-time.After(10 * time.Second): | ||
| t.Fatal("refresh did not return — lock not released?") | ||
| } | ||
| } | ||
|
|
||
| r := bdcounts.NewReaderAt(filepath.Join(cache, "counts.json")) | ||
| for _, root := range []string{a, b} { | ||
| if _, ok := r.Lookup(root); !ok { | ||
| t.Errorf("no counts.json row for %s — a concurrent refresh dropped it", root) | ||
| } | ||
| } | ||
| state, err := os.ReadFile(filepath.Join(cache, "counts-mtimes")) | ||
| if err != nil { | ||
| t.Fatalf("read state: %v", err) | ||
| } | ||
| for _, root := range []string{a, b} { | ||
| if !strings.Contains(string(state), root+"\t") { | ||
| t.Errorf("no counts-mtimes entry for %s — a concurrent refresh dropped it", root) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestRefreshLockTimesOutOnWedgedHolder: a holder that never releases must not hang a | ||
| // refresh forever — past the wait the run fails loudly so launchd's next fire does not | ||
| // pile up blocked refreshers behind it. | ||
| func TestRefreshLockTimesOutOnWedgedHolder(t *testing.T) { | ||
| cache := t.TempDir() | ||
| held := make(chan struct{}) | ||
| holding := make(chan struct{}) | ||
| go func() { | ||
| _ = withLock(context.Background(), cache, func() error { | ||
| close(holding) | ||
| <-held | ||
| return nil | ||
| }) | ||
| }() | ||
| <-holding | ||
| defer close(held) | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| cancel() // stands in for the lockWait deadline, without a 2-minute test | ||
| err := withLock(ctx, cache, func() error { | ||
| t.Error("fn ran while another holder had the lock") | ||
| return nil | ||
| }) | ||
| if err == nil { | ||
| t.Fatal("withLock returned nil while the lock was held") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
When another refresh holds
.refresh.lockfor more than 10 seconds, the production post-write path cannot cancel this wait:defaultRefreshCountsdiscards the context fromgoBackground(10*time.Second, ...), andcounts.Runinvokesrefreshwithcontext.Background(). Consequently, this new cancellation branch never observes either the server timeout orServer.Stop, so every queued post-write goroutine may wait for the full two-minutelockWait, whileStopblocks onbgWG; expose and use a context-awareRunpath here.Useful? React with 👍 / 👎.