Repository navigation
fix(pro): synchronize Go ArrayCache polling and clear - #30314
pcriadoperez wants to merge 2 commits into
Conversation
carlotestor
left a comment
There was a problem hiding this comment.
Summary
Reviewed at d661efb. I reproduced every claim in the description locally against this head rather than taking the body at its word; all of them hold.
Independent verification (Go 1.25.5, go/v4 resolved to the PR worktree via go list -m, confirming the local source was under test):
The tests are genuine regression gates, not tautologies. Reverting exchange_cache.go to origin/master while keeping the new test file reproduces real failures:
- The
GetLimitrace is exactly where you say it is —ArrayCacheByTimestamp.GetLimitwritingclearUpdatesagainstAppendreading it. Polling is provably not read-only:
WARNING: DATA RACE
Write at 0x00c0002b6318 by goroutine 12:
(*ArrayCacheByTimestamp).GetLimit() exchange_cache.go:503
Previous read at 0x00c0002b6318 by goroutine 11:
(*ArrayCacheByTimestamp).Append() exchange_cache.go:435
- The
Clearatomicity claim also holds. Restoring only the old two-lockClear(leaving the newGetLimitlocking in place) fails all five variants without-race, five repeats — e.g.clear exposed mismatched rows/index: 1/5,2/1,1/0. That is the logical interleaving bug, not a detector artifact, so the point in your description is correct.
Correctness of the locking itself. sync.Mutex is not reentrant, so the risk with this change is a self-deadlock from taking Mu in GetLimit. I checked the reachable set and found none:
- The nested caches (
ArrayCacheBySymbolById,ArrayCacheByOutcomeById,ArrayCacheBySymbolBySide) delegate toArrayCache.GetLimitexactly once, with no lock of their own. AppendInternal,trackAppendLocked,resetUpdateTrackersLockedandmergeRoware lock-free helpers, so the append paths still takeMuonce.MathMin/ToString/ParseInt, now called under the lock, are pure and touch no cache state.- No code outside
exchange_cache.golocksMu, and the generatedpro/*.gocallers reachGetLimitonly throughToGetsLimit(...)without holding it, so no caller can enter already-locked.
Inlining Data = Data[:0] is faithful to the old BaseCache.Clear() — on master that method was only Data[:0] under the lock, so no reset was dropped in the merge.
Findings
One durability issue with the regression gate, detailed inline: CI runs go test -C go -count=1 ./v4 without -race, and the timestamp half of this fix is invisible to that command. Not merge-blocking — the fix is correct either way — but worth knowing that half of what you just fixed would not be caught if it regressed.
The scope/limitations section is accurate and I would not expand it here: GetLimit + ToArray remaining two separately locked calls is a real property of the current watch/filter interface, and fixing it belongs in a broader change, not this one.
Merge gate: 🟢
Merge probability: 85%
| // GetLimit for timestamp cache ignores symbol because entries are not | ||
| // symbol-segmented. It mirrors the same precedence order as ArrayCache. | ||
| func (c *ArrayCacheByTimestamp) GetLimit(symbol any, limit any) any { | ||
| c.Mu.Lock() |
There was a problem hiding this comment.
This lock is the one CI will not protect. .github/workflows/go-app.yml runs go test -C go -count=1 ./v4 without -race, and I confirmed the difference matters: removing only these two lines (keeping the new atomic Clear) still passes go test -count=5 5/5 silently, while go test -race reports 12 data races. By contrast the ArrayCache.GetLimit mutation fails even without -race — it dies on fatal error: concurrent map read and map write — and the Clear mutation fails on the assertions. So the timestamp cache is the only part of this fix with no CI gate. Consider adding -race to the "Run Go Unit Tests" step (these tests run in ~4s under -race, so the cost is negligible), otherwise a future refactor can silently drop this lock.
There was a problem hiding this comment.
Added an explicit ArrayCache race-detector CI gate in 3e04957: go test -C go -race -count=1 -run '^TestArrayCache' ./v4. This runs for important/base changes alongside the ordinary Go unit tests, so timestamp counter races cannot pass on functional assertions alone. Locally the ArrayCache race suite passed with -count=20.
There was a problem hiding this comment.
Good invariant to write down. Worth noting that after this change BaseCache.Clear() has zero callers in the tree — both overrides now inline Data = Data[:0]. The "clear rows and metadata in one critical section" rule is therefore enforced only by convention in the two overrides, and a future cache type embedding BaseCache would reintroduce exactly the split you just removed by calling it. Consider stating that constraint on BaseCache.Clear itself so the next implementer sees it at the call site.
There was a problem hiding this comment.
Added the requested warning immediately above BaseCache.Clear in 3e04957: it clears base storage only; subclasses must clear Data and their own indexes/counters together under a single Mu critical section rather than calling BaseCache.Clear and reacquiring Mu.
carlotestor
left a comment
There was a problem hiding this comment.
Follow-up on 3e04957.
Delta is two files and no behaviour change to the cache logic reviewed at d661efb:
.github/workflows/go-app.ymladds ago test -C go -race -count=1 -run '^TestArrayCache' ./v4step, gated on the sameimportant_modified == 'true'condition as the existing./v4test step, and placed after it / before the Ws build. Anchoring the filter with^TestArrayCachepicks up the new suite (including theByTimestamppolling case that only fails under-race), and scoping the race detector to that one regex keeps CI time bounded instead of racing the whole package. Note the gate is conditional: pushes that do not touch the watched paths will not run it, so the race check protectsgo/v4changes rather than acting as an unconditional guard.go/v4/exchange_cache.goadds a doc comment onBaseCache.Cleartelling subclasses with indexes or counters to reset storage and metadata in a singleMucritical section rather than calling this method and re-locking. That is exactly the invariant the earlier commit enforced inArrayCacheBySymbolById/ByTimestamp, so the comment matches the shipped code.
Nothing new to flag; my earlier findings stand as addressed and this commit does not reopen any of them.
Merge gate: 🟢

Summary
Fix Go ArrayCache bookkeeping races discovered while verifying the independent performance PR #30300. This branch contains only the hand-written Go cache locking fix and native tests, not the performance changes.
ArrayCache.GetLimitandArrayCacheByTimestamp.GetLimitwith the same mutex used by append. Polling writes deferred-reset flags as well as reading counters; it is not read-only.Regression tests
Native tests cover plain, symbol/id, outcome/id, symbol/side and timestamp caches:
The new timestamp polling test reports
DATA RACEon the original implementation. The existing keyed-cache race was separately reproduced during #30300. A mutation restoring only the old two-lockClearmakes the atomicity regression fail for all five variants, even without-race(five repeats). This checks the logical interleaving bug, not just data races.The native cache race suite passed five repeats before rebasing onto current upstream, then 20 repeats on the final upstream base (Go 1.26.1, darwin/arm64). The production REST/Pro build and full WebSocket base suite also pass.
Scope and remaining limitations
This fixes cache-method synchronization, not all WebSocket concurrency:
GetLimitfollowed byToArrayis still two separately locked operations. A message can arrive between them. Exact per-watch count/snapshot consistency requires a broader change to the watch/filter interface; this PR does not introduce a new snapshot API or modify generated exchange files.ToArraycopies the container, not the row maps/slices. External reads or writes of those rows during an update still need synchronization. Direct access to publicData/Hashmapis not made thread-safe.GetLimitmethods delegate once, and no cache method holdingMucallsGetLimit.Clearno longer calls another mutex-acquiring clear method while holding the lock.Verification checklist
npm run lint— not relevant to this Go-only source change; no TS files modified. The separate performance branch's lint hook currently hits the TypeScript/ESLint compatibility error.npm run tsBuild— not rerun; no TS changes.npm run transpile(Python + PHP) — not applicable; hand-written Go cache only.npm run transpileCS+npm run buildCS— unchanged language, not rerun.npm run transpileGO— not applicable to this hand-written cache.go -C go build ./v4 ./v4/propasses.npm run transpileRust+npm run buildRust— unchanged language, not rerun.npm run check-python-syntax+npm run check-php-syntax— no Python/PHP code changes in this PR.go -C go test -race ./v4 -run TestArrayCache -count=20 -timeout=120spasses;go -C go run ./tests --baseTests --wsreportsBase WS tests passed!.git diff --checkpasses. No generated edits.