Skip to content

Fix lost-update race in channel subscriptions - #1045

Open
aegonmyy wants to merge 1 commit into
mattermost:masterfrom
aegonmyy:fix/subscription-lost-update
Open

Fix lost-update race in channel subscriptions#1045
aegonmyy wants to merge 1 commit 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. The plugin reported success to both callers, but only one
of the two changes actually persisted. The other was silently lost.

Root cause

StoreSubscriptions in server/plugin/subscriptions.go called
SetAtomicWithRetries, but its callback ignored the oldValue argument and
returned a snapshot of the subscriptions that was captured before the retry
loop began:

// server/plugin/subscriptions.go (before)
func (p *Plugin) StoreSubscriptions(s *Subscriptions) error {
	return p.store.SetAtomicWithRetries(SubscriptionsKey, func(_ []byte) (any, error) {
		modifiedBytes, err := json.Marshal(s)   // s was read before the loop
		...
	})
}

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

SetAtomicWithRetries is supposed to re-run its callback on each retry so the
change is re-applied on top of the freshly read value. Because the callback
ignored oldValue and always marshaled the same pre-read snapshot, a retry
after a conflicting concurrent write just rewrote stale data. The compare-and-set
still succeeded, so the concurrent writer's change was dropped without any error.

The fix

Move the read-modify-write inside the atomic 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.

AddSubscription and Unsubscribe now express their change as a mutate closure
passed to the helper. GetSubscriptions and GetSubscriptionsByChannel are
unchanged. Error types and return values are preserved.

Regression test

TestSubscriptionRace (server/plugin/subscription_race_test.go) fires 200
concurrent AddSubscription calls against the vendored pluginapi.MemoryStore,
whose SetAtomicWithRetries is byte-identical to the production KV service. 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:63: concurrent AddSubscription calls : 200
    subscription_race_test.go:64: AddSubscription returned success : 188
    subscription_race_test.go:65: subscriptions actually persisted : 1
    subscription_race_test.go:66: silently lost (success but gone) : 187
    subscription_race_test.go:69: lost-update bug: 187 subscriptions were reported as saved but silently dropped
--- FAIL: TestSubscriptionRace (0.06s)
FAIL

After the fix

=== RUN   TestSubscriptionRace
    subscription_race_test.go:63: concurrent AddSubscription calls : 200
    subscription_race_test.go:64: AddSubscription returned success : 111
    subscription_race_test.go:65: subscriptions actually persisted : 111
    subscription_race_test.go:66: silently lost (success but gone) : 0
--- PASS: TestSubscriptionRace (0.07s)

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: 🔴 High

Reasoning: The change modifies subscription persistence and data integrity logic. It also removes the public StoreSubscriptions method.

Regression Risk: Concurrent subscription updates and unsubscribe behavior changed. The main race path has regression coverage, but other mutation and storage-error paths remain possible regression areas.

** QA Recommendation:** Perform focused manual QA for concurrent add, unsubscribe, duplicate, not-found, and storage-error cases. Skipping manual QA carries moderate risk because the change affects shared persistence behavior.

Generated by CodeRabbitAI

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

Copy link
Copy Markdown
Contributor

Hello @aegonmyy,

Thanks for your pull request! A Core Committer will review your pull request soon. For code contributions, you can learn more about the review process here.

Per the Mattermost Contribution Guide, we need to add you to the list of approved contributors for the Mattermost project.

Please help complete the Mattermost contribution license agreement?
Once you have signed the CLA, please comment with /check-cla and confirm that the CLA check is green.

This is a standard procedure for many open source projects.

Please let us know if you have any questions.

We are very happy to have you join our growing community! If you're not yet a member, please consider joining our Contributors community channel to meet other contributors and discuss new opportunities with the core team.

@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: 1fdcd3a9-1ed7-4d28-bcb8-e56279f16a91

📥 Commits

Reviewing files that changed from the base of the PR and between b67cf15 and c3b8ae8.

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

📝 Walkthrough

Walkthrough

Subscription creation and removal now use a shared atomic read-modify-write helper. The change removes StoreSubscriptions, updates unsubscribe tests, and adds a concurrent race-regression test.

Changes

Subscription update consistency

Layer / File(s) Summary
Atomic subscription mutation core
server/plugin/subscriptions.go
AddSubscription now uses modifySubscriptions. The helper reloads state on retries, initializes missing data, applies mutations, and marshals the result.
Atomic unsubscribe handling
server/plugin/subscriptions.go
Unsubscribe uses atomic removal and distinguishes not-found, mutation, and storage errors.
Subscription concurrency and mock coverage
server/plugin/command_test.go, server/plugin/subscription_race_test.go
Tests use an atomic modification helper and verify that concurrent successful additions remain persisted.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: avasconcelos114, jgheithcock, nang2049

Poem

A rabbit checks each stored byte,
And retries through the busy night.
New hops join without a race,
Old links leave their proper place.
Atomic burrows hold them tight.

🚥 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: preventing lost-update races 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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 from b67cf15 to 80f94ca Compare August 2, 2026 18:31

@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.

🧹 Nitpick comments (1)
server/plugin/subscription_race_test.go (1)

14-30: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Remove or tighten the "byte-identical" claim.

pluginapi.MemoryStore exists for the pinned github.com/mattermost/mattermost/server/public v0.3.0, but the comment should not say it is byte-identical unless the stored source is inspected. Use the generic CAS retry wording already present after the test logic.

🤖 Prompt for 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.

In `@server/plugin/subscription_race_test.go` around lines 14 - 30, Update the
comments above TestSubscriptionRace to remove the unverified “byte-identical”
claim about pluginapi.MemoryStore and describe only the generic CAS retry
behavior relevant to the test. Keep the explanation that the test exercises real
AddSubscription logic without mocks and verifies persisted subscriptions match
reported successes.
🤖 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.

Nitpick comments:
In `@server/plugin/subscription_race_test.go`:
- Around line 14-30: Update the comments above TestSubscriptionRace to remove
the unverified “byte-identical” claim about pluginapi.MemoryStore and describe
only the generic CAS retry behavior relevant to the test. Keep the explanation
that the test exercises real AddSubscription logic without mocks and verifies
persisted subscriptions match reported successes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc610e8e-4bdb-4b30-a25d-886ce0e01d38

📥 Commits

Reviewing files that changed from the base of the PR and between e0c82d1 and b67cf15.

📒 Files selected for processing (3)
  • server/plugin/command_test.go
  • server/plugin/subscription_race_test.go
  • server/plugin/subscriptions.go

StoreSubscriptions passed a snapshot captured before the retry loop into
SetAtomicWithRetries and ignored the callback's oldValue argument, so a
conflicting concurrent write was overwritten with stale data while the
compare-and-set still 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 the atomic callback via a new
modifySubscriptions helper. Each retry re-reads the fresh blob and re-applies
the mutation, so no update is lost. AddSubscription and Unsubscribe now express
their change as a mutate closure.

Add TestSubscriptionRace, which fires 200 concurrent AddSubscription calls at
the real pluginapi.MemoryStore 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 80f94ca to c3b8ae8 Compare August 2, 2026 18:35
@aegonmyy

aegonmyy commented Aug 2, 2026

Copy link
Copy Markdown
Author

/check-cla


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Similar to mattermost/mattermost-plugin-gitlab#691.)
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")
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants