Skip to content

Fix lost-update race in channel subscriptions - #691

Open
aegonmyy wants to merge 2 commits into
mattermost:masterfrom
aegonmyy:fix/subscription-lost-update
Open

Fix lost-update race in channel subscriptions#691
aegonmyy wants to merge 2 commits into
mattermost:masterfrom
aegonmyy:fix/subscription-lost-update

Conversation

@aegonmyy

@aegonmyy aegonmyy commented Aug 2, 2026

Copy link
Copy Markdown

Fix lost-update race in channel subscriptions

Summary

All channel subscriptions are stored in a single KV key (subscriptions).
Every mutation reads the whole blob, changes it in memory, and writes the whole
blob back. That read-modify-write was not atomic, so two subscribe or
unsubscribe operations running at the same time in different channels could
overwrite each other. Both callers were told the operation succeeded, but only
one of the two changes actually persisted. The other was silently lost.

Root cause

StoreSubscriptions in server/subscriptions.go wrote the whole blob with a
plain Set and no compare-and-set, and it also swallowed any write error:

// server/subscriptions.go (before)
func (p *Plugin) StoreSubscriptions(s *Subscriptions) error {
	if _, err := p.client.KV.Set(SubscriptionsKey, s); err != nil {
		p.client.Log.Warn("can't set subscriptions in kvstore", "err", err.Error())
	}
	return nil   // always reports success, even when the write failed
}

AddSubscription (subscriptions.go:65) and Unsubscribe
(subscriptions.go:159) both did GetSubscriptions() to read the whole blob,
mutated the in-memory copy, then handed that copy to StoreSubscriptions.

Because the write used a plain Set, a concurrent writer that had read the same
starting blob would overwrite the first writer's change with its own copy. The
last write won and every earlier change was dropped. Since the error was
swallowed, a genuinely failed write also reported success.

The fix

Switch to the atomic SetAtomicWithRetries pattern exposed by the pluginapi KV
service, and move the read-modify-write inside its callback. A new
modifySubscriptions helper decodes oldValue on every attempt, applies a
mutate closure to that fresh state, and returns the re-encoded result. On a
conflicting write the retry re-reads the latest blob and re-applies the change,
so nothing is lost. The real store error is now returned to the caller instead
of being swallowed.

AddSubscription and Unsubscribe now express their change as a mutate closure
and keep their existing return signatures (Unsubscribe still reports whether a
subscription was removed and returns the resulting subscriptions).
GetSubscriptions and GetSubscriptionsByChannel are unchanged.

Regression test

TestSubscriptionRace (server/subscription_race_test.go) fires 200 concurrent
AddSubscription calls against an in-memory KV store whose KVGet and
KVSetWithOptions implement real compare-and-set semantics, matching the server
KV store. It then compares how many calls reported success against how many
subscriptions actually persisted. It fails on the old code and passes with this
fix.

Before the fix

=== RUN   TestSubscriptionRace
    subscription_race_test.go:106: concurrent AddSubscription calls : 200
    subscription_race_test.go:107: AddSubscription returned success : 200
    subscription_race_test.go:108: subscriptions actually persisted : 10
    subscription_race_test.go:109: silently lost (success but gone) : 190
    subscription_race_test.go:112: lost-update bug: 190 subscriptions were reported as saved but silently dropped
--- FAIL: TestSubscriptionRace (0.00s)
FAIL

All 200 calls reported success because the old code swallowed write errors, yet
only 10 subscriptions persisted.

After the fix

=== RUN   TestSubscriptionRace
    subscription_race_test.go:106: concurrent AddSubscription calls : 200
    subscription_race_test.go:107: AddSubscription returned success : 123
    subscription_race_test.go:108: subscriptions actually persisted : 123
    subscription_race_test.go:109: silently lost (success but gone) : 0
--- PASS: TestSubscriptionRace (0.06s)

After the fix, every call that reports success is durable. Under heavy
contention some calls exhaust the five internal retries and return an error
instead. Those are reported as failures and are not counted as success, which is
the correct behavior.

Change Impact: 🟠 Medium

Reasoning: The change modifies subscription persistence and concurrency handling. The scope is isolated, but it affects data integrity and removes the public StoreSubscriptions method.

Regression Risk: Medium. The atomic write flow changes AddSubscription and Unsubscribe, including error handling and removal behavior. A concurrency regression test covers concurrent additions, but other mutation paths remain less covered.

** QA Recommendation:** Perform focused manual QA for concurrent updates, replacements, unsubscriptions, store errors, and consumers of StoreSubscriptions. Skipping manual QA carries moderate risk because the change affects persisted subscription data.

Generated by CodeRabbitAI

@aegonmyy
aegonmyy requested a review from a team as a code owner August 2, 2026 18:29
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 24929467-b7e5-4205-8a6f-f207086402e0

📥 Commits

Reviewing files that changed from the base of the PR and between 4550822 and 8ab8a0e.

📒 Files selected for processing (1)
  • server/subscription_race_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/subscription_race_test.go

📝 Walkthrough

Walkthrough

Subscription creation and removal now use atomic read-modify-write operations. A concurrent test uses an in-memory compare-and-set KV implementation to detect lost subscriptions.

Changes

Subscription update flow

Layer / File(s) Summary
Atomic mutation helper
server/subscriptions.go
modifySubscriptions decodes state, applies retry-safe mutations, initializes missing maps, and persists compare-and-set updates. The exported StoreSubscriptions method was removed.
Atomic subscription creation
server/subscriptions.go
AddSubscription performs replacement or append operations through modifySubscriptions.
Atomic removal and race validation
server/subscriptions.go, server/subscription_race_test.go
Unsubscribe removes matching project and namespace subscriptions atomically. The test validates persisted subscriptions after 200 concurrent additions using a mutex-protected compare-and-set KV implementation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Test
  participant AddSubscription
  participant modifySubscriptions
  participant KVStore
  Test->>AddSubscription: issue 200 concurrent additions
  AddSubscription->>modifySubscriptions: mutate subscription state
  modifySubscriptions->>KVStore: read and compare-and-set state
  KVStore-->>modifySubscriptions: current state or retry
  modifySubscriptions-->>AddSubscription: report result
  Test->>KVStore: retrieve persisted subscriptions
  KVStore-->>Test: return stored subscriptions
Loading

Poem

A rabbit guards each channel bright,
Atomic writes protect the night.
Two hundred hops race through the den,
Stored and successful counts match again.
No subscription slips from sight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: fixing the lost-update race in channel subscriptions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@aegonmyy
aegonmyy force-pushed the fix/subscription-lost-update branch 2 times, most recently from db632e4 to b1b6f9f Compare August 2, 2026 18:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/subscriptions.go`:
- Around line 195-242: Update Unsubscribe’s result handling after
modifySubscriptions to check err before removed, returning the store error
immediately; treat errStopModify as the expected no-subscription case, while
preserving the existing removed/current return behavior for successful
modifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a00066a3-10a8-4694-8c45-b2a6aa6e7cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 6673c91 and c7149d9.

📒 Files selected for processing (2)
  • server/subscription_race_test.go
  • server/subscriptions.go

Comment thread server/subscriptions.go
StoreSubscriptions wrote the whole subscriptions blob with a plain
p.client.KV.Set, with no compare-and-set, and swallowed any write error (it
logged a warning and returned nil, so a failed write reported success).
AddSubscription and Unsubscribe both read the whole blob, mutated it in memory,
then called StoreSubscriptions, so concurrent subscribe/unsubscribe across
channels silently clobbered each other.

Move the read-modify-write inside an atomic SetAtomicWithRetries callback via a
new modifySubscriptions helper. Each retry re-reads the fresh blob and
re-applies the mutation, so no update is lost, and the real store error is now
returned to the caller instead of being swallowed. AddSubscription and
Unsubscribe now express their change as a mutate closure.

Add TestSubscriptionRace, which fires 200 concurrent AddSubscription calls at an
in-memory KV store with real compare-and-set semantics and asserts persisted ==
reported-success. It fails on the old code and passes with this fix.
@aegonmyy
aegonmyy force-pushed the fix/subscription-lost-update branch from b1b6f9f to 4550822 Compare August 3, 2026 02:57

if silentlyLost > 0 {
t.Fatalf("lost-update bug: %d subscriptions were reported as saved but silently dropped", silentlyLost)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If all AddSubscription calls fail, subs will be empty and persisted will be 0 and so silentlyLost will be 0 and no failures will be reported. Suggest adding the following after silentlyLost check:

if reportedSuccess == 0 {
    t.Fatalf("No AddSubscription calls succeeded")
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, addressed. Added the same guard after the silentlyLost check: the test now fails with "no AddSubscription calls succeeded" if reportedSuccess == 0, so it can't pass without actually exercising the race. Pushed in 8ab8a0e.

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.

2 participants