Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: faster addMessagesSorted #470

Merged
merged 10 commits into from
Oct 20, 2020
130 changes: 52 additions & 78 deletions src/channel_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@ import {
UserResponse,
} from './types';

const byDate = (
a: { created_at: Date | Immutable.ImmutableDate },
b: { created_at: Date | Immutable.ImmutableDate },
) => {
if (!a.created_at) return -1;

if (!b.created_at) return 1;

return a.created_at.getTime() - b.created_at.getTime();
};

/**
* ChannelState - A container class for the channel state.
*/
Expand Down Expand Up @@ -227,71 +216,40 @@ export class ChannelState<
>[],
initializing = false,
) {
// parse all the new message dates and add __html for react
const parsedMessages: ReturnType<
ChannelState<
AttachmentType,
ChannelType,
CommandType,
EventType,
MessageType,
ReactionType,
UserType
>['messageToImmutable']
>[] = [];
for (const message of newMessages) {
for (let i = 0; i < newMessages.length; i += 1) {
const message = this.messageToImmutable(newMessages[i]);

if (initializing && message.id && this.threads[message.id]) {
// If we are initializing the state of channel (e.g., in case of connection recovery),
// then in that case we remove thread related to this message from threads object.
// This way we can ensure that we don't have any stale data in thread object
// and consumer can refetch the replies.
this.threads = this.threads.without(message.id);
}
const parsedMsg = this.messageToImmutable(message);

parsedMessages.push(parsedMsg);

if (!this.last_message_at) {
this.last_message_at = new Date(parsedMsg.created_at.getTime());
this.last_message_at = new Date(message.created_at.getTime());
}

if (
this.last_message_at &&
parsedMsg.created_at.getTime() > this.last_message_at.getTime()
) {
this.last_message_at = new Date(parsedMsg.created_at.getTime());
if (message.created_at.getTime() > this.last_message_at.getTime()) {
this.last_message_at = new Date(message.created_at.getTime());
}
}

// update or append the messages...
const updatedThreads: string[] = [];
for (const message of parsedMessages) {
const isThreadReply = !!(message.parent_id && !message.show_in_channel);
// update or append the messages...
const parentID = message.parent_id;

// add to the main message list
if (!isThreadReply) {
if (!parentID || message.show_in_channel) {
this.messages = this._addToMessageList(this.messages, message);
}

// add to the thread if applicable..
const parentID: string | undefined = message.parent_id;
if (parentID) {
const thread = this.threads[parentID] || Immutable([]);
const threadMessages = this._addToMessageList(thread, message);
this.threads = this.threads.set(parentID, threadMessages);
updatedThreads.push(parentID);
}
}

// Resort the main messages and the threads that changed...
const messages = Immutable.asMutable(this.messages);
messages.sort(byDate);
this.messages = Immutable(messages);
for (const parentID of updatedThreads) {
const threadMessages = this.threads[parentID]
? Immutable.asMutable(this.threads[parentID])
: [];
threadMessages.sort(byDate);
this.threads = this.threads.set(parentID, threadMessages);
}
}

addReaction(
Expand Down Expand Up @@ -483,7 +441,7 @@ export class ChannelState<
>['messageToImmutable']
>
>,
newMessage: ReturnType<
message: ReturnType<
ChannelState<
AttachmentType,
ChannelType,
Expand All @@ -495,35 +453,51 @@ export class ChannelState<
>['messageToImmutable']
>,
) {
let updated = false;
let newMessages: Immutable.ImmutableArray<ReturnType<
ChannelState<
AttachmentType,
ChannelType,
CommandType,
EventType,
MessageType,
ReactionType,
UserType
>['messageToImmutable']
>> = Immutable([]);

for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const idMatch = !!message.id && !!newMessage.id && message.id === newMessage.id;
// for empty list just concat and return
if (messages.length === 0) return messages.concat(message);

const messageTime = message.created_at.getTime();

// if message is newer than last item in the list concat and return
if (messages[messages.length - 1].created_at.getTime() < messageTime)
return messages.concat(message);

// find the closest index to push the new message
let left = 0;
let middle = 0;
let right = messages.length - 1;
while (left <= right) {
middle = Math.floor((right + left) / 2);
Copy link
Contributor

Choose a reason for hiding this comment

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

In most cases wouldn't a bottom up approach be faster than a binary search given the likelihood of a message being further up is less?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

L462 takes care of that. cases like pagination could add some 100 more messages to the other end of the array which will be O(n), right now this part is O(log(n)) which is quite fast already

if (messages[middle].created_at.getTime() <= messageTime) left = middle + 1;
else right = middle - 1;
}

if (idMatch) {
// if message already exists, update and return
if (message.id) {
if (messages[left] && message.id === messages[left].id)
// @ts-expect-error - ImmutableArray.set exists in the documentation but not in the DefinitelyTyped types
newMessages = messages.set(i, newMessage);
updated = true;
}
}
return messages.set(left, message);

if (!updated) {
newMessages = messages.concat([newMessage]);
if (messages[left - 1] && message.id === messages[left - 1].id)
// @ts-expect-error - ImmutableArray.set exists in the documentation but not in the DefinitelyTyped types
return messages.set(left - 1, message);
}

return newMessages;
const mutable = messages.asMutable() as Array<
ReturnType<
ChannelState<
AttachmentType,
ChannelType,
CommandType,
EventType,
MessageType,
ReactionType,
UserType
>['messageToImmutable']
>
>;
mutable.splice(left, 0, message);
return Immutable(mutable);
}

/**
Expand Down
173 changes: 173 additions & 0 deletions test/channel_state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import chai from 'chai';
import { v4 as uuidv4 } from 'uuid';
import { ChannelState } from '../src/channel_state';

const expect = chai.expect;

const generateMsg = (msg = {}) => {
const date = msg.date || new Date().toISOString();
return {
id: uuidv4(),
text: 'x',
html: '<p>x</p>\n',
type: 'regular',
user: { id: 'id' },
attachments: [],
latest_reactions: [],
own_reactions: [],
reaction_counts: null,
reaction_scores: {},
reply_count: 0,
created_at: date,
updated_at: date,
mentioned_users: [],
silent: false,
status: 'received',
__html: '<p>x</p>\n',
...msg,
};
};

describe('ChannelState addMessagesSorted', function () {
it('empty state add single messages', async function () {
const state = new ChannelState();
expect(state.messages).to.have.length(0);
state.addMessagesSorted([
generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }),
]);
expect(state.messages).to.have.length(1);
state.addMessagesSorted([
generateMsg({ id: '1', date: '2020-01-01T00:00:01.000Z' }),
]);

expect(state.messages).to.have.length(2);
expect(state.messages[0].id).to.be.equal('0');
expect(state.messages[1].id).to.be.equal('1');
});

it('empty state add multiple messages', async function () {
const state = new ChannelState();
state.addMessagesSorted([
generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }),
generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }),
generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }),
]);

expect(state.messages).to.have.length(3);
expect(state.messages[0].id).to.be.equal('0');
expect(state.messages[1].id).to.be.equal('1');
expect(state.messages[2].id).to.be.equal('2');
});

it('update a message in place 1', async function () {
const state = new ChannelState();
state.addMessagesSorted([generateMsg({ id: '0' })]);
state.addMessagesSorted([{ ...state.messages[0].asMutable(), text: 'update' }]);

expect(state.messages).to.have.length(1);
expect(state.messages[0].text).to.be.equal('update');
});

it('update a message in place 2', async function () {
const state = new ChannelState();
state.addMessagesSorted([
generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }),
generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }),
generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }),
]);

state.addMessagesSorted([{ ...state.messages[1].asMutable(), text: 'update' }]);

expect(state.messages).to.have.length(3);
expect(state.messages[1].text).to.be.equal('update');
expect(state.messages[0].id).to.be.equal('0');
expect(state.messages[1].id).to.be.equal('1');
expect(state.messages[2].id).to.be.equal('2');
});

it('update a message in place 3', async function () {
const state = new ChannelState();
state.addMessagesSorted([
generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }),
generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }),
generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }),
generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }),
]);

state.addMessagesSorted([{ ...state.messages[0].asMutable(), text: 'update 0' }]);
expect(state.messages).to.have.length(4);
expect(state.messages[0].text).to.be.equal('update 0');

state.addMessagesSorted([{ ...state.messages[2].asMutable(), text: 'update 2' }]);
expect(state.messages).to.have.length(4);
expect(state.messages[2].text).to.be.equal('update 2');

state.addMessagesSorted([{ ...state.messages[3].asMutable(), text: 'update 3' }]);
expect(state.messages).to.have.length(4);
expect(state.messages[3].text).to.be.equal('update 3');
});

it('add a message with same created_at', async function () {
const state = new ChannelState();

for (let i = 0; i < 10; i++) {
state.addMessagesSorted([
generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.00${i}Z` }),
]);
}

for (let i = 10; i < state.messages.length - 1; i++) {
for (let j = i + 1; i < state.messages.length - 1; j++)
expect(state.messages[i].created_at.getTime()).to.be.lessThan(
state.messages[j].created_at.getTime(),
);
}

expect(state.messages).to.have.length(10);
state.addMessagesSorted([
generateMsg({ id: 'id', date: `2020-01-01T00:00:00.007Z` }),
]);
expect(state.messages).to.have.length(11);
expect(state.messages[7].id).to.be.equal('7');
expect(state.messages[8].id).to.be.equal('id');
});

it('add lots of messages in order', async function () {
const state = new ChannelState();

for (let i = 100; i < 300; i++) {
state.addMessagesSorted([
generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` }),
]);
}

expect(state.messages).to.have.length(200);
for (let i = 100; i < state.messages.length - 1; i++) {
for (let j = i + 1; j < state.messages.length - 1; j++)
expect(state.messages[i].created_at.getTime()).to.be.lessThan(
state.messages[j].created_at.getTime(),
);
}
});

it('add lots of messages out of order', async function () {
const state = new ChannelState();

const messages = [];
for (let i = 100; i < 300; i++) {
messages.push(generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` }));
}
// shuffle
for (let i = messages.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[messages[i], messages[j]] = [messages[j], messages[i]];
}

state.addMessagesSorted(messages);

expect(state.messages).to.have.length(200);
for (let i = 0; i < 200; i++) {
expect(state.messages[i].id).to.be.equal(`${i + 100}`);
}
});
});