Skip to content

fix(treatment-service): remove nested RLock deadlock and add optional LocalStorage call metrics - #93

Open
anantadwi13 wants to merge 3 commits into
mainfrom
fix-deadlock-add-metrics
Open

fix(treatment-service): remove nested RLock deadlock and add optional LocalStorage call metrics#93
anantadwi13 wants to merge 3 commits into
mainfrom
fix-deadlock-add-metrics

Conversation

@anantadwi13

Copy link
Copy Markdown

What this PR does / why we need it:

  1. Fixes a deadlock in LocalStorage.findSubscribedProjectSettingsById: it took s.RLock()
    and then, while still holding it, called findProjectSettingsById, which took s.RLock()
    again on the same goroutine. Go's sync.RWMutex blocks new readers once a writer is queued
    (to prevent writer starvation), so if a writer (PollerService.Refresh -> Init(), or a
    pubsub InsertExperiment/UpdateProjectSettings handler) queued between the outer and
    nested RLock, the nested call would block forever waiting for the writer, and the writer
    would block forever waiting for the outer RLock to release — a genuine circular-wait
    deadlock, suspected to be the root cause of a reported GetTreatmentForRequest hang.

    Fix: split the lock-acquiring findProjectSettingsById from a new lock-free
    findProjectSettingsByIdLocked, and have the already-locked caller invoke the latter
    directly instead of re-acquiring the lock.

  2. Adds feature-flagged instrumentation on LocalStorage's public methods (a call counter
    and call-duration histogram, labeled by method name), so a stuck method — e.g. a lock
    deadlock like the one above — shows up as its call count stalling and duration climbing,
    instead of silently hanging with no observability.

    • New config flag: Monitoring.LocalStorageMetricsEnabled (default false).
    • Wired via a SetMetricsRecorder seam (LocalStorageMetricsRecorder interface) set from
      appcontext.NewAppContext, avoiding an import cycle between models and services.
    • New Prometheus metrics: local_storage_calls_total, local_storage_call_duration_ms.
    • Registered as Turing plugin collector metrics in plugins/turing/runner/experiment_runner.go.

    Tests: storage_deadlock_test.go deterministically reproduces the nested-RLock deadlock in
    isolation and stress-tests the real call path to confirm it no longer hangs;
    storage_metrics_test.go / metric_service_test.go cover the metrics recorder contract on
    both read and write paths.

anantadwi13 and others added 2 commits August 14, 2026 10:17
…ngs lookup

findSubscribedProjectSettingsById held s.RLock() and, while still holding it,
called findProjectSettingsById which took s.RLock() again on the same
goroutine. Go's sync.RWMutex blocks new readers once a writer is queued (to
avoid writer starvation), so if a writer (e.g. PollerService.Refresh -> Init,
or a pubsub update handler) queued between the outer and nested RLock, the
nested call blocked forever waiting for the writer, and the writer blocked
forever waiting for the outer RLock to release -- a circular-wait deadlock.

Extract the unlocked lookup into findProjectSettingsByIdLocked so callers that
already hold the lock no longer re-lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rage

Adds a call counter and call-duration histogram around LocalStorage's
public methods, gated behind a new Monitoring.LocalStorageMetricsEnabled
config flag. A stuck method (e.g. a lock deadlock) shows up as its call
counter climbing while its duration histogram's count stays frozen.

Routes through the existing MetricService (LogRequestCount /
LogLatencyHistogram) rather than a separate metrics backend, so this
respects the existing Monitoring.Kind sink and reuses the same
Prometheus registration path as every other treatment-service metric.
Since services already imports models (for *LocalStorage), models
can't import services back, so LocalStorage depends only on a small
LocalStorageMetricsRecorder interface (satisfied by MetricService
as-is); appcontext wires the two together after both are constructed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The simulated writer goroutine's Lock()/Unlock() pair is deliberately
empty -- acquiring and releasing the lock is the observable event under
test, not protection of shared state -- but staticcheck flags it as an
empty critical section, failing CI lint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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