add MemberWriter, and only return MemberChange's if something changed

This commit is contained in:
Bruno Windels 2021-03-05 17:03:45 +01:00
parent e97ed9ae45
commit f4a7782298
3 changed files with 246 additions and 72 deletions

View file

@ -103,36 +103,34 @@ export class RoomMember {
serialize() { serialize() {
return this._data; return this._data;
} }
equals(other) {
const data = this._data;
const otherData = other._data;
return data.roomId === otherData.roomId &&
data.userId === otherData.userId &&
data.membership === otherData.membership &&
data.displayName === otherData.displayName &&
data.avatarUrl === otherData.avatarUrl;
}
} }
export class MemberChange { export class MemberChange {
constructor(roomId, memberEvent) { constructor(member, previousMembership) {
this._roomId = roomId; this.member = member;
this._memberEvent = memberEvent; this.previousMembership = previousMembership;
this._member = null;
}
get member() {
if (!this._member) {
this._member = RoomMember.fromMemberEvent(this._roomId, this._memberEvent);
}
return this._member;
} }
get roomId() { get roomId() {
return this._roomId; return this.member.roomId;
} }
get userId() { get userId() {
return this._memberEvent.state_key; return this.member.userId;
}
get previousMembership() {
return getPrevContentFromStateEvent(this._memberEvent)?.membership;
} }
get membership() { get membership() {
return this._memberEvent.content?.membership; return this.member.membership;
} }
get hasLeft() { get hasLeft() {

View file

@ -0,0 +1,203 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
import {LRUCache} from "../../../../utils/LRUCache.js";
export class MemberWriter {
constructor(roomId) {
this._roomId = roomId;
this._cache = new LRUCache(5, member => member.userId);
}
writeTimelineMemberEvent(event, txn) {
return this._writeMemberEvent(event, false, txn);
}
writeStateMemberEvent(event, isLimited, txn) {
// member events in the state section when the room response
// is not limited must always be lazy loaded members.
// If they are not, they will be repeated in the timeline anyway.
return this._writeMemberEvent(event, !isLimited, txn);
}
async _writeMemberEvent(event, isLazyLoadingMember, txn) {
const userId = event.state_key;
if (!userId) {
return;
}
const member = RoomMember.fromMemberEvent(this._roomId, event);
if (!member) {
return;
}
let existingMember = this._cache.get(userId);
if (!existingMember) {
const memberData = await txn.roomMembers.get(this._roomId, userId);
if (memberData) {
existingMember = new RoomMember(memberData);
}
}
// either never heard of the member, or something changed
if (!existingMember || !existingMember.equals(member)) {
txn.roomMembers.set(member.serialize());
this._cache.set(member);
if (!isLazyLoadingMember) {
return new MemberChange(member, existingMember?.membership);
}
}
}
async lookupMember(userId, timelineEvents, txn) {
let member = this._cache.get(userId);
if (!member) {
const memberData = await txn.roomMembers.get(this._roomId, userId);
if (memberData) {
member = new RoomMember(memberData);
this._cache.set(member);
}
}
if (!member) {
// sometimes the member event isn't included in state, but rather in the timeline,
// even if it is not the first event in the timeline. In this case, go look for the
// first occurence
const memberEvent = timelineEvents.find(e => {
return e.type === MEMBER_EVENT_TYPE && e.state_key === userId;
});
if (memberEvent) {
member = RoomMember.fromMemberEvent(this._roomId, memberEvent);
// adding it to the cache, but not storing it for now;
// we'll do that when we get to the event
this._cache.set(member);
}
}
return member;
}
}
export function tests() {
function createMemberEvent(membership, userId, displayName, avatarUrl) {
return {
content: {
membership,
"displayname": displayName,
"avatar_url": avatarUrl
},
sender: userId,
"state_key": userId,
type: "m.room.member"
};
}
function createStorage(initialMembers = []) {
const members = new Map();
for (const m of initialMembers) {
members.set(m.userId, m);
}
return {
members,
roomMembers: {
async get(_, userId) {
return members.get(userId);
},
set(member) {
members.set(member.userId, member);
}
}
}
}
function member(...args) {
return RoomMember.fromMemberEvent(roomId, createMemberEvent.apply(null, args));
}
const roomId = "abc";
const alice = "@alice:hs.tld";
const avatar = "mxc://hs.tld/def";
return {
"new join through state": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage();
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn);
assert(change.hasJoined);
assert.equal(txn.members.get(alice).membership, "join");
},
"accept invite through state": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("invite", alice)]);
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn);
assert.equal(change.previousMembership, "invite");
assert(change.hasJoined);
assert.equal(txn.members.get(alice).membership, "join");
},
"change display name through timeline": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alies"), txn);
assert(!change.hasJoined);
assert.equal(change.member.displayName, "Alies");
assert.equal(txn.members.get(alice).displayName, "Alies");
},
"set avatar through timeline": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn);
assert(!change.hasJoined);
assert.equal(change.member.avatarUrl, avatar);
assert.equal(txn.members.get(alice).avatarUrl, avatar);
},
"ignore redundant member event": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice", avatar)]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn);
assert(!change);
},
"leave": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn);
assert(change.hasLeft);
assert(!change.hasJoined);
},
"ban": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("ban", alice, "Alice"), txn);
assert(change.hasLeft);
assert(!change.hasJoined);
},
"reject invite": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("invite", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn);
assert(!change.hasLeft);
assert(!change.hasJoined);
},
"lazy loaded member is written but no change returned": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage();
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alice"), false, txn);
assert(!change);
assert.equal(txn.members.get(alice).displayName, "Alice");
},
};
}

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,7 +19,8 @@ import {EventKey} from "../EventKey.js";
import {EventEntry} from "../entries/EventEntry.js"; import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.js"; import {createEventEntry} from "./common.js";
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
import {MemberWriter} from "./MemberWriter.js";
// Synapse bug? where the m.room.create event appears twice in sync response // Synapse bug? where the m.room.create event appears twice in sync response
// when first syncing the room // when first syncing the room
@ -37,6 +39,7 @@ function deduplicateEvents(events) {
export class SyncWriter { export class SyncWriter {
constructor({roomId, fragmentIdComparer}) { constructor({roomId, fragmentIdComparer}) {
this._roomId = roomId; this._roomId = roomId;
this._memberWriter = new MemberWriter(roomId);
this._fragmentIdComparer = fragmentIdComparer; this._fragmentIdComparer = fragmentIdComparer;
this._lastLiveKey = null; this._lastLiveKey = null;
} }
@ -130,37 +133,19 @@ export class SyncWriter {
return currentKey; return currentKey;
} }
_writeMember(event, txn) { async _writeStateEvents(roomResponse, memberChanges, isLimited, txn, log) {
const userId = event.state_key;
if (userId) {
const memberChange = new MemberChange(this._roomId, event);
const {member} = memberChange;
if (member) {
// TODO: can we avoid writing redundant members here by checking
// if this is not a limited sync and the state is not in the timeline?
txn.roomMembers.set(member.serialize());
return memberChange;
}
}
}
_writeStateEvent(event, txn) {
if (event.type === MEMBER_EVENT_TYPE) {
return this._writeMember(event, txn);
} else {
txn.roomState.set(this._roomId, event);
}
}
_writeStateEvents(roomResponse, memberChanges, txn, log) {
// persist state // persist state
const {state} = roomResponse; const {state} = roomResponse;
if (Array.isArray(state?.events)) { if (Array.isArray(state?.events)) {
log.set("stateEvents", state.events.length); log.set("stateEvents", state.events.length);
for (const event of state.events) { for (const event of state.events) {
const memberChange = this._writeStateEvent(event, txn); if (event.type === MEMBER_EVENT_TYPE) {
if (memberChange) { const memberChange = await this._memberWriter.writeStateMemberEvent(event, isLimited, txn);
memberChanges.set(memberChange.userId, memberChange); if (memberChange) {
memberChanges.set(memberChange.userId, memberChange);
}
} else {
txn.roomState.set(this._roomId, event);
} }
} }
} }
@ -177,20 +162,26 @@ export class SyncWriter {
// store event in timeline // store event in timeline
currentKey = currentKey.nextKey(); currentKey = currentKey.nextKey();
const entry = createEventEntry(currentKey, this._roomId, event); const entry = createEventEntry(currentKey, this._roomId, event);
let memberData = await this._findMemberData(event.sender, events, txn); let member = await this._memberWriter.lookupMember(event.sender, events, txn);
if (memberData) { if (member) {
entry.displayName = memberData.displayName; entry.displayName = member.displayName;
entry.avatarUrl = memberData.avatarUrl; entry.avatarUrl = member.avatarUrl;
} }
txn.timelineEvents.insert(entry); txn.timelineEvents.insert(entry);
entries.push(new EventEntry(entry, this._fragmentIdComparer)); entries.push(new EventEntry(entry, this._fragmentIdComparer));
// process live state events first, so new member info is available // update state events after writing event, so for a member event,
// we only update the member info after having written the member event
// to the timeline, as we want that event to have the old profile info
if (typeof event.state_key === "string") { if (typeof event.state_key === "string") {
timelineStateEventCount += 1; timelineStateEventCount += 1;
const memberChange = this._writeStateEvent(event, txn); if (event.type === MEMBER_EVENT_TYPE) {
if (memberChange) { const memberChange = await this._memberWriter.writeTimelineMemberEvent(event, txn);
memberChanges.set(memberChange.userId, memberChange); if (memberChange) {
memberChanges.set(memberChange.userId, memberChange);
}
} else {
txn.roomState.set(this._roomId, event);
} }
} }
} }
@ -199,24 +190,6 @@ export class SyncWriter {
return currentKey; return currentKey;
} }
async _findMemberData(userId, events, txn) {
// TODO: perhaps add a small cache here?
const memberData = await txn.roomMembers.get(this._roomId, userId);
if (memberData) {
return memberData;
} else {
// sometimes the member event isn't included in state, but rather in the timeline,
// even if it is not the first event in the timeline. In this case, go look for the
// first occurence
const memberEvent = events.find(e => {
return e.type === MEMBER_EVENT_TYPE && e.state_key === userId;
});
if (memberEvent) {
return RoomMember.fromMemberEvent(this._roomId, memberEvent)?.serialize();
}
}
}
/** /**
* @type {SyncWriterResult} * @type {SyncWriterResult}
* @property {Array<BaseEntry>} entries new timeline entries written * @property {Array<BaseEntry>} entries new timeline entries written
@ -233,7 +206,7 @@ export class SyncWriter {
const memberChanges = new Map(); const memberChanges = new Map();
// important this happens before _writeTimeline so // important this happens before _writeTimeline so
// members are available in the transaction // members are available in the transaction
this._writeStateEvents(roomResponse, memberChanges, txn, log); await this._writeStateEvents(roomResponse, memberChanges, timeline?.limited, txn, log);
const currentKey = await this._writeTimeline(entries, timeline, this._lastLiveKey, memberChanges, txn, log); const currentKey = await this._writeTimeline(entries, timeline, this._lastLiveKey, memberChanges, txn, log);
log.set("memberChanges", memberChanges.size); log.set("memberChanges", memberChanges.size);
return {entries, newLiveKey: currentKey, memberChanges}; return {entries, newLiveKey: currentKey, memberChanges};