[BUG] invite_user_to_group returns 500 after the membership is already committed — check-then-insert race in CreateGroupChatConversation
OpenIM Server Version
3.8.3-patch.12
Operating System and CPU Architecture
Linux (AMD)
Deployment Method
Docker Deployment
Bug Description and Steps to Reproduce
What happened
POST /group/invite_user_to_group intermittently returns errCode: 500 with a MongoDB duplicate-key error, even though the invited user is successfully added to the group:
bulk write exception: write errors: [E11000 duplicate key error
collection: openim_v3.conversation
index: owner_user_id_1_conversation_id_1
dup key: { owner_user_id: "g_9fc1f7", conversation_id: "sg_2778037994" }]
mongo insert manyWe verified the final state right after the failure via /group/get_group_member_list: the "failed" user is in the group, with a joinTime matching the exact millisecond of the call that returned 500.
{"userID": "g_9fc1f7", "roleLevel": 20, "joinTime": 1786565824079}So the operation actually succeeded — only the response says otherwise.
Note the duplicate is on the same (owner_user_id, conversation_id) pair, i.e. the same user's own group conversation being inserted twice. This is different from #3713 / #3722, which dealt with the global conversation_id_1 unique index and multiple owners sharing one conversation_id. Our deployment only has the composite index those PRs intentionally kept:
_id_ unique=false
owner_user_id_1_conversation_id_1 unique=trueEnvironment
- OpenIM Server
v3.8.3-patch.12, official Docker image running on Kubernetes - MongoDB 7.0.39
- Indexes on
openim_v3.conversationare the defaults created byNewConversationMongo(listed above)
How to reproduce
POST /group/create_group(group type 2, owner only, no initial members)- Immediately
POST /group/invite_user_to_groupto add one member
In our environment step 2 fires ~22ms after step 1. Repeating this hourly, the 500 shows up in roughly 0.6% of calls (2 out of 329 over 14 days). The narrow timing window is what makes it rare — a human-paced client would almost never hit it, but automated flows and tests do.
Root cause analysis
CreateGroupChatConversation in pkg/common/storage/controller/conversation.go is a check-then-insert:
existConversationUserIDs, err := c.conversationDB.FindUserID(ctx, userIDs, []string{conversationID}) // ① read
notExistUserIDs := stringutil.DifferenceString(userIDs, existConversationUserIDs) // ② diff
err = c.conversationDB.Create(ctx, conversations) // ③ insertand Create in pkg/common/storage/database/mgo/conversation.go is a plain
return mongoutil.InsertMany(ctx, c.coll, conversations)with no upsert, no $setOnInsert, and no mongo.IsDuplicateKeyError handling.
Between ① and ③ there is a window. When a concurrent path creates the same conversation document in that window, both sides see "does not exist" and both insert; the loser trips the unique index.
The surrounding c.tx.Transaction(...) does not prevent this: MongoDB transactions provide snapshot isolation but no predicate/gap locking, so both transactions can legitimately read "not found" and proceed to insert. The unique index is the last line of defence — but a duplicate-key error here means "someone else already reached the desired state", not "the operation failed".
The blast radius is amplified because on the group path this is a hard precondition: in internal/rpc/group/notification.go, GroupApplicationAgreeMemberEnterNotification calls CreateGroupChatConversations and returns immediately on error, so the whole invite reports failure — after CreateGroup has already committed the membership.
Why we think this is an oversight rather than an inherent limitation
Three things in the codebase point the same way:
- Upsert is already available right next door. In the same
mgo/conversation.go,Updateopts into upsert (mongoutil.UpdateOne(..., true)).Createsimply doesn't. - The message path already tolerates this exact collision.
CreateSingleChatConversationsininternal/rpc/conversation/conversation.go("create conversation without notification for msg redis transfer") swallows the creation error and only logs it:and returns success regardless.if err != nil { log.ZWarn(ctx, "create conversation failed", err, "conversation", conversation) } - The group path does the opposite — it treats the same failure as fatal for the entire invite.
So the same collision is tolerated in one caller and fatal in another.
Impact
The response code stops carrying usable information. A client receiving 500 from invite_user_to_group cannot tell whether the user was added:
- retry → possible duplicate side effects
- give up → silently drops an operation that actually succeeded
- read back the member list → every caller has to implement this independently
Callers currently have to verify the final state on every failure to stay correct.
Suggested fix
Either would resolve it:
- Make the insert idempotent — bulk upsert on
(owner_user_id, conversation_id), or ignoremongo.IsDuplicateKeyErrorinCreate, since a duplicate here means the desired document already exists. - Or make conversation backfill non-fatal on the group path, consistent with how
CreateSingleChatConversationsalready treats it — the membership is the primary operation, the conversation document is derived state.
The first seems preferable: it fixes the race for every caller rather than per call site.
Happy to send a PR if the maintainers agree on the direction.
Screenshots Link
No response
Source: openimsdk/open-im-server