[BUG] invite_user_to_group returns 500 after the membership is already committed — check-then-insert race in CreateGroupChatConversation

Author: leooooyCreated Aug 14, 2026Updated Aug 14, 2026
Labelsbug

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 many

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

json
{"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=true

Environment

  • OpenIM Server v3.8.3-patch.12, official Docker image running on Kubernetes
  • MongoDB 7.0.39
  • Indexes on openim_v3.conversation are the defaults created by NewConversationMongo (listed above)

How to reproduce

  1. POST /group/create_group (group type 2, owner only, no initial members)
  2. Immediately POST /group/invite_user_to_group to 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:

go
existConversationUserIDs, err := c.conversationDB.FindUserID(ctx, userIDs, []string{conversationID}) // ① read
notExistUserIDs := stringutil.DifferenceString(userIDs, existConversationUserIDs)                    // ② diff
err = c.conversationDB.Create(ctx, conversations)                                                    // ③ insert

and Create in pkg/common/storage/database/mgo/conversation.go is a plain

go
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:

  1. Upsert is already available right next door. In the same mgo/conversation.go, Update opts into upsert (mongoutil.UpdateOne(..., true)). Create simply doesn't.
  2. The message path already tolerates this exact collision. CreateSingleChatConversations in internal/rpc/conversation/conversation.go ("create conversation without notification for msg redis transfer") swallows the creation error and only logs it:
    go
    if err != nil {
        log.ZWarn(ctx, "create conversation failed", err, "conversation", conversation)
    }
    and returns success regardless.
  3. 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 ignore mongo.IsDuplicateKeyError in Create, since a duplicate here means the desired document already exists.
  • Or make conversation backfill non-fatal on the group path, consistent with how CreateSingleChatConversations already 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