trajectory

Fix a data race in a metrics registry without serialising every read

Run record

Model
stub:methodical
Seed
2
Temperature
0
Steps
9 of 40
Total cost
$0.00
Wall clock
9.38 s
Hidden tests
10 of 10 passed
Verify exit code
0
Verify duration
3.56 s
Started
16 Sep 2026, 18:43 UTC
Finished
16 Sep 2026, 18:43 UTC
Suite
core-12
Status
completed
Sandbox
docker
solved 10/10 hidden testsgo-race-01stub:methodicalseed 2docker9 steps$0.009.38 s

Trajectory

  1. 0bashexit 047 ms$0.00

    Read the contract before the code. Point four of it is the whole task: it rules out the fix that every tutorial on this error reaches for.

    bash command
    ls -R . && cat README.md
    output
    .:
    README.md
    agg
    cmd
    go.mod
    
    ./agg:
    aggregator.go
    aggregator_test.go
    histogram.go
    
    ./cmd:
    aggd
    
    ./cmd/aggd:
    main.go

    showing 16 of 51 lines

  2. 1bashexit 0312 ms$0.00

    Reproduce it and read the report properly. The detector names the field, both stacks and the goroutines, which is a more reliable map of what is unsynchronised than reading the file will be.

    bash command
    export PATH=/usr/local/go/bin:$PATH; go test -race -count=1 ./agg/ 2>&1 | head -60
    output
    ==================
    WARNING: DATA RACE
    Read at 0x00c0000b2508 by goroutine 17:
      example.internal/aggregator/agg.(*Registry).lookup()
          /workspace/agg/aggregator.go:79 +0x44
      example.internal/aggregator/agg.(*Registry).Observe()
          /workspace/agg/aggregator.go:67 +0x46
      example.internal/aggregator/agg_test.TestConcurrentObserveKeepsEveryUpdate.func1()
          /workspace/agg/aggregator_test.go:78 +0x117
      example.internal/aggregator/agg_test.TestConcurrentObserveKeepsEveryUpdate.gowrap1()
          /workspace/agg/aggregator_test.go:80 +0x41
    
    Previous write at 0x00c0000b2508 by goroutine 14:
      example.internal/aggregator/agg.(*Registry).lookup()
          /workspace/agg/aggregator.go:90 +0x1e5
      example.internal/aggregator/agg.(*Registry).Observe()

    showing 16 of 61 lines

  3. 2read_fileno exit code31 ms$0.00

    Now the code. mu covers the map and nothing else: the bucket fields, the histogram and the observed counter are all touched outside it, and lookup reads and writes the hot cache with nothing held.

    arguments
    {
      "path": "agg/aggregator.go"
    }

    3 lines, 33 B

    output
    // Package agg aggregates per endpoint request latency for a service that serves a few
    // thousand requests a second across a handful of endpoints.
    //
    // One Registry is shared by every request goroutine and by the exporter goroutine that
    // scrapes it once a second. Observe is on the request path. Snapshot, SnapshotAll,
    // Endpoints and Observed are on the exporter's path. Neither path is allowed to make the
    // other wait longer than it has to, and two exporter reads of the same endpoint must be
    // able to run at the same time: quantile walks the histogram in place, so a read is not
    // cheap and serialising reads behind one lock turns a one second scrape into a stall.
    //
    // This started life single threaded. A mutex was added later, around the map, which is
    // where it stopped.
    package agg
    
    import (
    	"sort"

    showing 16 of 146 lines

  4. 3read_fileno exit code31 ms$0.00

    This file is out of bounds, so read it for the constraint rather than the fix. It says a histogram carries no lock of its own and that quantile reads it in place, which means the read path has to hold the same lock the writers take while it runs.

    arguments
    {
      "path": "agg/histogram.go"
    }

    3 lines, 32 B

    output
    package agg
    
    import (
    	"math"
    	"time"
    )
    
    // bucketCount buckets cover one microsecond to just over eight seconds, in powers of two.
    const bucketCount = 24
    
    // quantileCost stands in for the interpolation pass the real exporter runs across the
    // buckets and the reservoir it keeps beside them.
    //
    // It is a sleep rather than a busy loop on purpose. What matters about the read path is
    // whether two readers can be inside it at the same time, and a busy loop would measure
    // how many cores the box has instead.

    showing 16 of 65 lines

  5. 4write_fileno exit code46 ms$0.00

    One lock per bucket, RWMutex so the exporter's reads share, the map lock held only long enough to resolve a name, and the counter moved to an atomic. The hot cache goes: it existed to skip a map lookup that now costs a shared read lock, locking it would reintroduce the contention it was added to avoid, and it was what made Reset leave a bucket behind.

    content
    // Package agg aggregates per endpoint request latency for a service that serves a few
    // thousand requests a second across a handful of endpoints.
    //
    // One Registry is shared by every request goroutine and by the exporter goroutine that
    // scrapes it once a second. Observe is on the request path. Snapshot, SnapshotAll,
    // Endpoints and Observed are on the exporter's path. Neither path is allowed to make the
    // other wait longer than it has to, and two exporter reads of the same endpoint must be
    // able to run at the same time: quantile walks the histogram in place, so a read is not
    // cheap and serialising reads behind one lock turns a one second scrape into a stall.
    //
    // Locking, from the outside in. Registry.mu guards the map itself and nothing else, held
    // for a map lookup and released. Each bucket carries its own RWMutex, which is the lock
    // the histogram is documented to need: writers take it exclusively, readers share it, so
    // two Snapshots of the same endpoint overlap and a Snapshot of one endpoint never waits
    // on writes to another. observed is an atomic because it is touched by every writer and
    // read by the exporter, and a counter is the one thing that does not need a lock at all.
    package agg
    

    showing 18 of 163 lines

    arguments
    {
      "path": "agg/aggregator.go"
    }

    3 lines, 33 B

    output
    Wrote 4577 bytes (163 lines) to agg/aggregator.go.
  6. 5bashexit 0108 ms$0.00

    Cheap checks first. A vet finding or a formatting diff is not worth a race detector run.

    bash command
    export PATH=/usr/local/go/bin:$PATH; gofmt -l . && go vet ./... && echo clean
    output
    clean
    

    2 lines, 6 B

  7. 6bashexit 01.36 s$0.00

    The suite under the detector, which is the only evidence that counts here. Note that a passing run proves nothing about interleavings it did not take, which is why the reasoning above is about which lock covers which field rather than about the result.

    bash command
    export PATH=/usr/local/go/bin:$PATH; go test -race -count=1 -v ./... 2>&1 | tail -20
    output
    ?   	example.internal/aggregator/cmd/aggd	[no test files]
    === RUN   TestObserveThenSnapshot
    --- PASS: TestObserveThenSnapshot (0.01s)
    === RUN   TestSnapshotAllCoversEveryEndpoint
    --- PASS: TestSnapshotAllCoversEveryEndpoint (0.02s)
    === RUN   TestConcurrentObserveKeepsEveryUpdate
    --- PASS: TestConcurrentObserveKeepsEveryUpdate (0.03s)
    === RUN   TestExporterScrapesWhileRequestsLand
    --- PASS: TestExporterScrapesWhileRequestsLand (0.00s)
    PASS
    ok  	example.internal/aggregator/agg	1.068s
    

    12 lines, 487 B

  8. 7bashexit 0187 ms$0.00

    Run the thing. The report has to show every observation accounted for, which is the lost update half of the contract and is not something the detector checks.

    bash command
    export PATH=/usr/local/go/bin:$PATH; go run ./cmd/aggd
    output
    observed 3000 of 3000
    /v1/cart         count=1000   mean=319    max=549    p95=1024
    /v1/checkout     count=1000   mean=319    max=549    p95=1024
    /v1/inventory    count=1000   mean=319    max=549    p95=1024
    

    5 lines, 208 B

  9. 8finishno exit code0 ms$0.00

    Close with which lock covers what, because that is the part a reviewer has to check.

    summary
    Registry.mu guarded the map only. The per endpoint aggregate fields, the histogram behind them and the observed counter were all mutated with no lock held, and the hot endpoint cache in front of the map was read and written unsynchronised, which is also why Reset left a stale bucket live. Fixed by giving each bucket its own RWMutex (writers exclusive, readers shared, held across quantile because it reads the histogram in place), holding the map's RWMutex only for the lookup, making the counter an atomic, and dropping the cache. Readers of one endpoint now overlap each other and never wait on writes to another endpoint. go test -race ./... and go vet ./... are clean.
    

    2 lines, 675 B

    output
    Run ended by the agent.

9 steps5 commands0 schema violations0 failed commands0 destructive attempts

Metrics for this run

Partial credit
100.0%
Step efficiency
1.000
Tool validity
100.0%
Redundancy
0.0%
Recovery
n/a
Context drift
n/a
Commands
5
Schema violations
0
Failed commands
0
Destructive attempts
0

Verification output

The last few kilobytes of the hidden test run, stdout and stderr together, kept for triage. The agent never saw this.

=== RUN   TestProvidedFilesAreUnmodified
--- PASS: TestProvidedFilesAreUnmodified (0.00s)
=== RUN   TestPublicSurfaceIsUnchanged
--- PASS: TestPublicSurfaceIsUnchanged (0.00s)
=== RUN   TestSnapshotReportsCorrectAggregates
--- PASS: TestSnapshotReportsCorrectAggregates (0.01s)
=== RUN   TestSnapshotAllAgreesWithSnapshot
--- PASS: TestSnapshotAllAgreesWithSnapshot (0.05s)
=== RUN   TestResetClearsEveryEndpoint
--- PASS: TestResetClearsEveryEndpoint (0.01s)
=== RUN   TestConcurrentReadsDoNotSerialise
--- PASS: TestConcurrentReadsDoNotSerialise (0.02s)

showing 12 of 23 lines

Provenance
Harness
0.1.1
Schema
1
Sandbox
docker
Image
sha256:ed16e4cd351a639a32250a983cbc63408e567da3b117e55d2ded8ef934f43d18
OS
Windows 11
Arch
AMD64
Python
3.12.13
Docker
29.8.0
CPUs
24
CI
no
Command timeout
300s
Run timeout
1200s
Output cap
16384 bytes
Budget
none
Tools
bash, read_file, write_file, list_dir, finish
Workspace files
6 after, 6 before

Results that cannot be reproduced are not results. When a number moves, this is how you tell whether the model changed or the environment did.