Skip to content

Fix histogram data race under -workers>1 + clean reporter shutdown - #119

Merged
fcostaoliveira merged 2 commits into
masterfrom
fix/histogram-race-116
Jul 13, 2026
Merged

Fix histogram data race under -workers>1 + clean reporter shutdown#119
fcostaoliveira merged 2 commits into
masterfrom
fix/histogram-race-116

Conversation

@fcostaoliveira

Copy link
Copy Markdown
Contributor

Closes #116.

Problem

hdrhistogram is not concurrency-safe, but the plain per-label / total / inst_* histograms were recorded from every worker goroutine (work()), and read + Reset() by the periodic report() goroutine, with no lock. So:

  • the reported percentiles (OverallQuantiles, the live median column) were computed from racily-mutated state, and increments were lost;
  • a race-instrumented binary reported 9–51 DATA RACEs at the default --workers 8.

(The op/byte counts were already made exact via the atomic counters in #115; the two histogram maps were already mutex-guarded. This PR closes the remaining plain-histogram race.)

There was also a leaked reporter goroutine: report() looped on time.NewTicker(period).C with no exit, so it kept touching the histograms and the *Ts slices past wg.Wait() — racing the final GetTimeSeriesMap/summary read-out (a distinct slice append-vs-sort race).

Fix

  • histogramsMutex guards all plain-histogram writes on the worker record path and the reporter's reads + inst Reset(). Counters stay atomic; the two maps keep their own mutexes.
  • Extract recordCmdStat so the per-command record path is unit-testable (and simplify the label switch).
  • Clean reporter shutdown: stopReport/reportDone + a select loop with the ticker stopped. RunBenchmark stops the reporter after wg.Wait() and before the final read-out, so it no longer runs concurrently with GetTimeSeriesMap/summary/GetOverallQuantiles — fixing the timeseries race too. Reporter values are snapshotted under the lock so the log I/O stays out of the critical section.

Validation

  • Race-instrumented binary, --workers 8, 15k HSETs: 0 DATA RACEs (was 9–51); TotalOps == dbsize exact; quantiles sane (q50≈0.11ms, q99≈0.31ms).
  • TestRecordCmdStatConcurrentIsRaceFree — 8 writers + a reporter-style reader; verified it fails with DATA RACE under -race when the lock is removed, passes with it.
  • TestReportStopsCleanly — the reporter exits within 2s of close(stopReport) (no goroutine leak).
  • make integration-test now runs with -race so the benchmark_runner concurrency tests actually detect a regression. Full suite green under -race.

Scope

Percentile/latency histograms only. The per-command error-count inflation in pipelines (#118) and the reply-capture latency cost (#117) remain separate.

…116)

hdrhistogram is not concurrency-safe, but the plain per-label/total/inst
histograms were recorded from every worker goroutine (and read/reset by the
periodic reporter) with no lock -- so percentiles were computed from racily
mutated state and increments were lost (a race-instrumented binary reported
9-51 DATA RACEs at the default -workers 8). The op/byte counts were already
exact via the atomic counters from #115; this fixes the percentile corruption.

- Add histogramsMutex guarding all plain-histogram writes (worker record path)
  and the reporter's reads + inst Reset. The two histogram maps keep their own
  mutexes; the counters stay atomic.
- Extract the per-command recording into recordCmdStat so the record path is
  unit-testable, and simplify the label switch.
- Give the periodic reporter a clean shutdown (stopReport/reportDone + a select
  loop, ticker stopped): it now exits before the final read-out, so it no longer
  races GetTimeSeriesMap/summary over the histograms or the *Ts slices (fixes the
  leaked-reporter-goroutine timeseries race too). Reporter reads are snapshotted
  under the lock, keeping the log I/O out of the critical section.

Tests: TestRecordCmdStatConcurrentIsRaceFree (fails under -race if the lock is
removed -- verified) and TestReportStopsCleanly. integration-test now runs with
-race so the benchmark_runner concurrency tests actually detect regressions.

Validated E2E: race-instrumented binary, -workers 8, 0 DATA RACEs (was 9-51);
TotalOps==dbsize exact; quantiles sane.

Closes #116
…eport() concurrently

Two fixes from the pre-merge adversarial review:

- Bound the reporter-shutdown join. RunBenchmark previously did an unbounded
  `<-reportDone`. The reporter's only unbounded blocking point is its progress
  log write (outside histogramsMutex), so a wedged output consumer could stall it
  there and hang the whole run at the join. Wait with a 2s timeout instead; on
  timeout, take+release histogramsMutex as a happens-before barrier against a
  reporter improbably descheduled mid-critical-section (a reporter stalled in its
  log write holds no lock and does no further shared access once it unblocks).
  Normal runs are unaffected (the reporter's select returns immediately on
  stopReport). Note: this does NOT fully fix the pre-existing "hang on a stalled
  consumer -> no JSON" bug (summary() also logs before writing the result); that
  affects master too and is tracked separately.

- Add TestReportConcurrentWithRecordIsRaceFree, which drives the REAL report()
  goroutine concurrently with recordCmdStat writers and reads after shutdown.
  The existing test only exercised a hand-rolled reader, so a report()-side lock
  regression would have slipped through; verified the new test fails with a
  DATA RACE under -race when report()'s histogram reads are unlocked.

- Fix a stale comment (the benchmark_runner race guards run under
  `make integration-test`, not `make unit-test`).
@fcostaoliveira

Copy link
Copy Markdown
Contributor Author

15-way adversarial review (Opus 4.8) on the current HEAD — verdict: merge. Race-freedom empirically proven across a workers{1,4,8,16}×pipeline{1,4} + duration matrix (0 DATA RACEs; guard mutation-verified), extraction/shutdown/lock-scope/regression all clean, and a fair A/B shows no throughput regression (mutex is off the measured-latency path). The final reviewer caught one real issue: the unbounded <-reportDone join could hang if the output consumer stalls — now bounded with a 2s timeout + a happens-before fence. (That's not a regression: master hangs identically on a stalled consumer; the underlying 'no JSON on a wedged consumer' bug is pre-existing and filed as #121.) Also added TestReportConcurrentWithRecordIsRaceFree to guard report()'s own locking (4 reviewers flagged the coverage gap).

@sonarqubecloud

Copy link
Copy Markdown

@fcostaoliveira
fcostaoliveira merged commit 15b36c8 into master Jul 13, 2026
3 checks passed
@fcostaoliveira
fcostaoliveira deleted the fix/histogram-race-116 branch July 13, 2026 16:07
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.

ftsb_redisearch: histogram RecordValue is data-raced under -workers >1 (corrupts percentiles + counts)

1 participant