Updates to the MongooseIM Inbox in version 5.1

This article describes new improvements to the Inbox feature of MongooseIM – an open-source XMPP server. We added “boxes” so you can keep your inbox organized as well as a lot of performance improvements in the new 5.1 release

User interfaces in open protocols

When a messaging client starts, it typically presents the user with:

  • an inbox
  • a summary of chats (in chronological order)
  • unread messages in their conversation
  • a snippet of the most recent message in the conversation
  • information on if a conversation is muted (and if so how long a conversation is muted for)
  • other information that users may find useful on their welcome screen

MongooseIM as a messaging server uses XMPP as its underlying protocol, and to gather the data needed above, traditional XMPP makes it possible to build a summary by combining knowledge of the users’ roster (friend list), the archive, and possibly private storage for any extensions, but this means far too many round-trips between the server and the client to build the initial UI, a number of round-trips that worsens the more out-of-date the client is, and which also imposes a load on the server to answer them all.

MongooseIM Inbox open extension

For this, MongooseIM introduced an extension called inbox, back in 3.0.0, that simply keeps track of the active conversations, the snippet with the last message, and a count of unread messages for each conversation which is, intuitively, restarted when a user sends a new message or a chat marker pointing to the latest message in the chat.

In retrospect

In 3.5.0, we introduced a mechanism to manually set the unread count of a conversation to zero, which is a common operation for a user, when reading the conversation is as undesired as continuing to see it pop up with unread messages, but on 4.2.0 we took this really seriously: we introduced a mechanism to not only set a conversation as read, but also to set it as unread again, to mark a conversation as muted until a certain date, and to unmute it as well.

Until now we had not added a function to set as unread because it would have been easy to introduce a problematic API and it was deemed out-of-scope for the effort at that time: if we allowed a client to set any count they desire, we might allow broken client software to break the experience of a good client software. the same user might use separately, for example by setting a number much higher than the actual number of messages in the conversation. And there’s also the chance of setting a number at the same time more messages arrive, that would therefore invalidate the count: should the number requested be considered an absolute number, or an increment? In the end, we settled for an atomic “set-as-one-if-it-was-zero”, i.e., if the conversation already had unread messages, the request does nothing. If the conversation was fully read, the request only sets it as strictly one. This way, a user cannot possibly move away from the actual number of unread messages, set incorrect numbers, or accidentally set a number at the same time more messages arrive.

But this is not all, what if the user doesn’t want to be notified about a certain conversation? For example, they might say “mute this chat for an hour”, and all their devices should sync on the fact that the chat shouldn’t be making the phone buzz, and maybe the logo should be displayed differently. 4.2.0 introduces a possibility for that as well! A client might provide the number of seconds they want a conversation muted for, and MongooseIM will tell all his other devices, persist this information on the inbox summary, and propagate this information to be picked up by other services, like possibly the push-notification one.

Classifications

One more functionality (apart from a myriad of performance optimizations!) added in the latest MongooseIM 5.1, the concept of boxes. Like in an email service, a user can classify chats in boxes like “favourites”, “travel”, “business”, or “home”. You can set a box for a chat and then when reconnecting focus on that box specifically in MongooseIM, and build a nice UI accordingly, including a “pinned” box of conversations you always want at the top!

Configuration

To use this functionality, it is as easy as enabling the inbox module in your MongooseIM configuration, all the defaults are set for you. Out-of-the-box the inbox will have the “inbox”, “archive” and “bin” boxes enabled, and the trash bin will be periodically cleaned every hour for entries older than 30 days. Group chats are enabled, chats are considered read when a displayed marker is sent, and the synchronous backend is chosen. To go further away from the details, try enabling the asynchronous backend, see below for the scalability details.

It always comes down to scalability

Another important feature is the creation of an asynchronous backend for inbox. Until now, inbox operations were synchronous in the context of a message processing pipeline: that means that the message processing would be blocked by the database operation, until the database successfully returned. This becomes especially bad for big group-chats where a message updates the inbox of each room participant. This means that MongooseIM’s message processing throughput would be as scalable as the database is, which is bad news.

Releasing the Database

In order to untie MongooseIM’s scalability from the database performance, we needed to first make the inbox processing asynchronous, that is, the message processing pipeline will only “submit a task” to update the appropriate inbox. But this still leaves the fact that the inbox generates too many database operations. So we tried to do less of them: the trick was that inbox is (mostly) an update-only operation: Alice receives a message from Bob, we update Alice’s inbox’s snippet with the new message, and an incremented count of one, Alice receives a new message, we update the snippet again with an incremented count of another one. We could have just applied the second update with an incremented count of two instead!

% If we don't have any request pending, it means that it is the first task submitted,
% so aggregation is not needed.
handle_task(_, Value, #state{async_request = no_request_pending} = State) ->
    State#state{async_request = make_async_request(Value, State)};
handle_task(Key, NewValue, #state{aggregate_callback = Aggregator,
                                  flush_elems = Acc,
                                  flush_queue = Queue,
                                  flush_extra = Extra} = State) ->
    case Acc of
        #{Key := OldValue} ->
            case Aggregator(OldValue, NewValue, Extra) of
                {ok, FinalValue} ->
                    State#state{flush_elems = Acc#{Key := FinalValue}};
                {error, Reason} ->
                    ?LOG_ERROR(log_fields(State, #{what => aggregation_failed, reason => Reason})),
                    State
            end;
        _ ->
            % The queue is used to ensure the order in which elements are flushed,
            % so that first requests are first flushed.
            State#state{flush_elems = Acc#{Key => NewValue},
                        flush_queue = queue:in(Key, Queue)}
    end.

The new inbox asynchronous backend does just this: it submits updates to the database immediately, but instead of waiting for the database’s response, it aggregates all upcoming requests in the meantime. This means that on an unloaded server inbox is still pretty much immediate, and it will saturate the database throughput as fast as messages arrive: but once the database is saturated, it will not overload it!

aggregate({set_inbox_incr_unread, _, _, _, _, Incrs2, _},
          {set_inbox_incr_unread, Entry, Content, MsgId, Timestamp, Incrs1, Box}) ->
    {set_inbox_incr_unread, Entry, Content, MsgId, Timestamp, Incrs1 + Incrs2, Box};

To enable this backend, all you need to do is to select “rdbms_async” as the inbox backend in your mongooseim.toml configuration file and you’re good to go!

Note some comparisons:

rdbms
12k users, rooms
of 5 user
rdbms_async
30k users, rooms of 10 users
No inbox
35k users, rooms of 10 users
Delivery time, .99>40s
50ms
50ms
Stanzas sent per minute140k180k180k
CPU usage80%99%99%
Memory usage800MB1200MB950MB

Note how for the traditional backend the CPU is not even saturated and messages are terribly stalled, but for the new asynchronous backend, we saturate the CPU with more than twice the number of users, and get pretty fast delivery times. Even when compared to disabling inbox altogether, the regular backend penalty shoots above 70% of the throughput, while the new backend costs only 12% (35k users lowers to 30k).

Keep reading

Why do systems fail? Tandem NonStop system and fault tolerance

Why do systems fail? Tandem NonStop system and fault tolerance

Explore the NonStop architecture's influence on Elixir, Gleam, and Erlang developers. Learn about modularity, fault containment, and process-pairs design for resilient software systems.

Erlang Concurrency: Evolving for Performance

Erlang Concurrency: Evolving for Performance

Erlang’s concurrency model, built for scalability, now competes with C and Rust in performance, powering messaging systems and large-scale platforms.

Elixir, 7 steps to start your journey

Elixir, 7 steps to start your journey

Embark on a journey into Elixir with our series "Elixir, 7 Steps to Start Your Journey" by Lorena Mireles.