Skip to content

fix(state/in-memory): avoid deadlock between Close and cleanup goroutine - #4496

Open
karprabha wants to merge 5 commits into
dapr:mainfrom
karprabha:fix/in-memory-close-deadlock
Open

fix(state/in-memory): avoid deadlock between Close and cleanup goroutine#4496
karprabha wants to merge 5 commits into
dapr:mainfrom
karprabha:fix/in-memory-close-deadlock

Conversation

@karprabha

@karprabha karprabha commented Aug 1, 2026

Copy link
Copy Markdown

Description

Close() calls wg.Wait() while holding the write lock. The cleanup goroutine
started by Init() needs that same lock in doCleanExpiredItems(). If the
cleanup ticker fires in the window between Close() acquiring the lock and the
goroutine reaching Lock(), the two wait on each other and Close() never
returns.

This is a permanent hang — no timeout, no error, no retry. Under Kubernetes it
surfaces as a sidecar stuck terminating until SIGKILL, with nothing in the logs.

Fix: wait for the cleanup goroutine to exit before acquiring the lock.
Once wg.Wait() returns, nothing can contend for it.

Introduced in d098e38 (#2474, Feb 2023).

Reproduction

The window is microseconds wide, so it reproduces under scheduling pressure
rather than precise timing: many stores closed concurrently, timed to coincide
with their cleanup tick. It is not single-core specific — measured on a 10-core
machine, 6 rounds per configuration:

GOMAXPROCS stores before after
1 2000 6/6 deadlocked 0/6
2 2000 4/6 0/6
4 2000 5/6 0/6
10 2000 3/6 0/6
10 4 1/6 0/6

Goroutine dump at the deadlock:

goroutine [sync.Mutex.Lock]:
  ...doCleanExpiredItems()  in_memory.go:444   <- blocked on store.lock
goroutine [sync.WaitGroup.Wait]:
  ...Close()                in_memory.go:90    <- holds store.lock

Cleanup ticker now uses the injected clock

startCleanThread used time.After directly, so the cleanup tick ignored the
clock injected into the store. It now uses store.clock.After, with the interval
hoisted into a package constant shared with the test.

This means a fake clock now governs the sweeper as well - fakeClock.Step in
TestReadAndWrite drives a sweep, so tests get full control of the component's
time rather than a background goroutine ticking on real time underneath them.
Existing tests pass unchanged.

Issue reference

Closes #4495

Checklist

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation
    • Created the dapr/docs PR: N/A

No documentation changes are required: this is an internal concurrency fix with
no change to component behavior, configuration metadata, or public API.

Close called wg.Wait while holding the write lock, but the cleanup goroutine started by Init needs that same lock in doCleanExpiredItems. If the cleanup ticker fired between Close acquiring the lock and the goroutine reaching Lock, the two waited on each other and Close never returned.

Wait for the cleanup goroutine to exit before acquiring the lock, and add a regression test that deadlocks reliably on the previous code.

Introduced in d098e38 (dapr#2474).

Signed-off-by: Prabhakar Yadav <developer.prabhakaryadav@gmail.com>
@karprabha
karprabha requested review from a team as code owners August 1, 2026 08:52

@mikeee mikeee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please consider including the proposed changes to startCleanThread in this PR to reduce the CI run load?

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 32.14%. Comparing base (cc03682) to head (6c3d2f0).
⚠️ Report is 25 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4496      +/-   ##
==========================================
+ Coverage   31.94%   32.14%   +0.20%     
==========================================
  Files         353      353              
  Lines       47723    37916    -9807     
==========================================
- Hits        15243    12188    -3055     
+ Misses      31277    24524    -6753     
- Partials     1203     1204       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… clock

startCleanThread called time.After directly, so the cleanup tick ignored the clock injected into the store. Use store.clock.After and hoist the interval into a package constant shared with the regression test.

Signed-off-by: Prabhakar Yadav <developer.prabhakaryadav@gmail.com>
@karprabha

Copy link
Copy Markdown
Author

Done, pushed. startCleanThread now uses store.clock.After with the interval as a package constant, so the sweeper follows the injected clock like the rest of the component. Tests get full control of the component's time now - fakeClock.Step in TestReadAndWrite drives a sweep. Existing tests pass unchanged; -race and make lint clean.

Separately, pubsub.mqtt3 certification failed. This only touches state/in-memory and state.in-memory conformance passed, so it looks unrelated.

@karprabha
karprabha requested a review from mikeee August 3, 2026 10:23
Comment thread state/in-memory/in_memory_test.go Outdated
Now that the cleanup ticker runs on the injected clock, the failing interleaving can be produced directly instead of with scheduler pressure: park the cleanup goroutine on its tick, step the fake clock so it commits to the cleanup branch of its select, and call Close before it is scheduled again. One store and no sleeps instead of 2000 stores and a 1s wait. Fails on the first attempt in 3/3 runs with the fix reverted, passes with it.

Signed-off-by: Prabhakar Yadav <developer.prabhakaryadav@gmail.com>
@karprabha
karprabha requested a review from mikeee August 3, 2026 12:01
@karprabha

Copy link
Copy Markdown
Author

Hi @mikeee I've made the said changes Kindly review it again

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.

state/in-memory: Close() can deadlock with the cleanup goroutine

2 participants