Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/@webex/plugin-meetings/src/member/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,5 +439,31 @@ const MemberUtil = {

return participantUrl;
},

/**
* Collects all CSIs reported for a participant across all their devices,
* looking at both the top-level `csis` array and any `mediaSessions[].csi` values.
*
* @param {Object} participant - The locus participant object.
* @returns {Array<number>} unique CSIs for this participant
*/
extractCsis: (participant: Participant): number[] => {
const csis = new Set<number>();

participant?.devices?.forEach((device: any) => {
device?.csis?.forEach((csi: number) => {
if (typeof csi === 'number') {
csis.add(csi);
}
});
device?.mediaSessions?.forEach((mediaSession: any) => {
if (typeof mediaSession?.csi === 'number') {
csis.add(mediaSession.csi);
}
});
});

return Array.from(csis);
},
};
export default MemberUtil;
61 changes: 58 additions & 3 deletions packages/@webex/plugin-meetings/src/members/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*!
* Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
*/
import {get, isEmpty, set} from 'lodash';
import {get, isEmpty, isEqual, set} from 'lodash';
// @ts-ignore
import {StatelessWebexPlugin} from '@webex/webex-core';

Expand All @@ -15,6 +15,7 @@ import {
} from '../constants';
import Trigger from '../common/events/trigger-proxy';
import Member from '../member';
import MemberUtil from '../member/util';
import LoggerProxy from '../common/logs/logger-proxy';
import ParameterError from '../common/errors/parameter';
import {
Expand Down Expand Up @@ -99,6 +100,14 @@ export default class Members extends StatelessWebexPlugin {
selfId: any;
type: any;

/**
* Map of memberId -> CSIs that the member used in previous Locus updates.
* Kept here (rather than on each Member) so it survives members being removed
* and re-added (e.g. when entering/leaving a breakout session).
* @private
*/
private historyCsisByMemberId: Map<string, Set<number>> = new Map();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggestion: we're only using this to find a member for a specific csi, so maybe instead of having a map of sets we could just have a map where the keys are CSI and values are memberId? It would also simplify the code:

for storing it, we would just call:
historyCsisByMemberId.set(csi, member.id)
and that's it no other checks or code needed

and for reading it:
memberId=historyCsisByMemberId.get(csi)
and that's it, no for loop or anything else

what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I considered that approach as well. Personally, I feel that using memberId as the key provides a more member-centric view of the data, which makes the overall data model clearer.

That said, I agree that using the data can be more convenient when csi is the key. If you think using csi as the key is the better approach, I'm happy to switch to that instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The main downside of current approach is that we have to loop over all the entries in findMemberByCsi() - when we have a 1k meeting or in the future 5k meetings in theory we may have to be doing a loop over thousands of members and findMemberByCsi() is called whenever the person shown in one of video panes changes, so that can be quite often in a large meeting where more than 6 people are speaking (although maybe in practice not all members will have entries in historyCsisByMemberId?), so that's the main reason why I think using csi as key would be better.


namespace = MEETINGS;

/**
Expand Down Expand Up @@ -569,6 +578,17 @@ export default class Members extends StatelessWebexPlugin {
*/
private removeMembers(removedMembers: Array<string>) {
removedMembers.forEach((memberId) => {
// capture CSIs before removal so findMemberByCsi can still resolve them
// if the member is later re-added (e.g. when leaving a breakout session)
// with a participant payload that no longer contains the original CSIs
const existingMember = this.membersCollection.get(memberId);
if (existingMember) {
const history = new Set<number>();
MemberUtil.extractCsis(existingMember.participant).forEach((csi) => history.add(csi));
if (history.size > 0) {
this.historyCsisByMemberId.set(memberId, history);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge cached CSIs on removal

Fresh evidence beyond the earlier thread is that this newly added removal path overwrites any existing CSI history for the member. When a member already has cached CSIs from an earlier update and the later removedParticipantIds delta still has a current/partial CSI set, this replaces the old set with only the CSIs extracted at removal time, so findMemberByCsi can no longer resolve delayed media-layout CSIs after the member is re-added with incomplete devices; the new unit case named “merges existing history with CSIs captured at removal time” exercises this sequence as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we're overwriting the history entry here, instead we should be adding new CSIs to it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't this introduce redundant data? If a signed-in user joins the meeting again, the CSI generated during the first join would already be invalid. I'm not sure whether an expired CSI could later be reused by another participant.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yes, actually, you're right, if CSIs change we shouldn't really need the old ones for anything.

}
}
this.membersCollection.remove(memberId);
});
}
Expand Down Expand Up @@ -598,6 +618,21 @@ export default class Members extends StatelessWebexPlugin {
set(member, prop, existingValue);
}
});

// remember CSIs the member used previously so findMemberByCsi can still
// resolve a CSI even after participant.devices[].csis no longer contains it.
// Skip re-extracting when devices haven't changed - existing history already covers them.
const devicesUnchanged = isEqual(
existingMember.participant?.devices,
member.participant?.devices
);
if (!devicesUnchanged) {
const history = new Set<number>();
MemberUtil.extractCsis(existingMember.participant).forEach((csi) => history.add(csi));
if (history.size > 0) {
this.historyCsisByMemberId.set(member.id, history);
Comment thread
WeijuanShao marked this conversation as resolved.
Outdated
Comment thread
WeijuanShao marked this conversation as resolved.
Outdated
Comment thread
marcin-bazyl marked this conversation as resolved.
Outdated
}
}
}
}
this.membersCollection.set(member.id, member);
Expand Down Expand Up @@ -1161,18 +1196,38 @@ export default class Members extends StatelessWebexPlugin {
);
}

/** Finds a member that has any device with a csi matching provided value
/** Finds a member that has any device with a csi matching provided value.
* Falls back to the `historyCsisByMemberId` map so that a CSI a member used in
* a previous Locus update can still be resolved even if it is no longer
* present in `participant.devices[].csis`.
*
* @param {number} csi
* @returns {Member}
*/
findMemberByCsi(csi) {
return Object.values(this.membersCollection.getAll()).find((member) =>
const members = Object.values(this.membersCollection.getAll());

const currentMatch = members.find((member) =>
// @ts-ignore
member.participant?.devices?.find((device) =>
device.csis?.find((memberCsi) => memberCsi === csi)
)
);

if (currentMatch) {
return currentMatch;
}

for (const [memberId, history] of this.historyCsisByMemberId) {
if (history.has(csi)) {
const member = this.membersCollection.get(memberId);
Comment thread
marcin-bazyl marked this conversation as resolved.
Outdated
if (member) {
return member;
}
}
}

return undefined;
}

/**
Expand Down
65 changes: 65 additions & 0 deletions packages/@webex/plugin-meetings/test/unit/spec/member/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,68 @@ describe('extractMediaStatus', () => {
assert.deepEqual(mediaStatus, {audio: 'RECVONLY', video: 'SENDRECV'});
});
});

describe('MemberUtil.extractCsis', () => {
it('returns an empty array when participant is undefined', () => {
assert.deepEqual(MemberUtil.extractCsis(undefined), []);
});

it('returns an empty array when participant has no devices', () => {
assert.deepEqual(MemberUtil.extractCsis({}), []);
});

it('returns an empty array when devices have no csis or mediaSessions', () => {
const participant = {devices: [{}, {csis: [], mediaSessions: []}]};

assert.deepEqual(MemberUtil.extractCsis(participant), []);
});

it('collects CSIs from device.csis arrays', () => {
const participant = {
devices: [{csis: [1, 2]}, {csis: [3]}],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 2, 3]);
});

it('collects CSIs from device.mediaSessions[].csi values', () => {
const participant = {
devices: [
{mediaSessions: [{csi: 10}, {csi: 11}]},
{mediaSessions: [{csi: 12}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [10, 11, 12]);
});

it('merges CSIs from both csis and mediaSessions, deduplicated', () => {
const participant = {
devices: [
{csis: [1, 2], mediaSessions: [{csi: 2}, {csi: 3}]},
{csis: [3, 4], mediaSessions: [{csi: 5}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 2, 3, 4, 5]);
});

it('ignores non-numeric csi values', () => {
const participant = {
devices: [
{csis: [1, '2', null, undefined]},
{mediaSessions: [{csi: 'abc'}, {csi: null}, {csi: 3}, {}]},
],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [1, 3]);
});

it('skips falsy devices and mediaSessions entries', () => {
const participant = {
devices: [null, undefined, {csis: [7], mediaSessions: [null, {csi: 8}, undefined]}],
};

assert.deepEqual(MemberUtil.extractCsis(participant), [7, 8]);
});
});
146 changes: 146 additions & 0 deletions packages/@webex/plugin-meetings/test/unit/spec/members/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ describe('plugin-meetings', () => {
);
sinon.restore();
});

it('should not clear historyCsisByMemberId so it survives member removal (e.g. BO entry/exit)', () => {
const members = createMembers({url: url1});
members.historyCsisByMemberId.set('test1', new Set([1000]));
members.clearMembers();
assert.strictEqual(members.historyCsisByMemberId.size, 1);
assert.deepEqual([...members.historyCsisByMemberId.get('test1')], [1000]);
});
});
describe('#locusParticipantsUpdate', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1165,6 +1173,144 @@ describe('plugin-meetings', () => {
it('returns correct member when CSI matches the second device', () => {
assert.strictEqual(members.findMemberByCsi(2001), fakeCollection.oneWithSomeCsis);
});

it('falls back to historyCsisByMemberId when CSI is no longer on the current devices', () => {
members.historyCsisByMemberId.set('oneWithDevicesWithoutCsis', new Set([9999]));
assert.strictEqual(
members.findMemberByCsi(9999),
fakeCollection.oneWithDevicesWithoutCsis
);
});

it('returns undefined when the historyCsisByMemberId entry points to a removed member', () => {
members.historyCsisByMemberId.set('not-in-collection', new Set([8888]));
assert.strictEqual(members.findMemberByCsi(8888), undefined);
});

it('prefers a current device match over a historyCsisByMemberId match', () => {
members.historyCsisByMemberId.set('oneWithDevicesWithoutCsis', new Set([1001]));
assert.strictEqual(members.findMemberByCsi(1001), fakeCollection.oneWithSomeCsis);
});
});

describe('historyCsisByMemberId tracking', () => {
let members;

const participantWithCsis = (id, csis) => ({
id,
type: 'USER',
person: {},
devices: [{csis}],
});

beforeEach(() => {
sinon.stub(Trigger, 'trigger');
members = createMembers({url: url1});
});

afterEach(() => {
sinon.restore();
});

it('does not populate the map when adding a brand new member', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
assert.strictEqual(members.historyCsisByMemberId.size, 0);
});

it('does not populate the map when devices have not changed between updates', () => {
const devices = [{csis: [1000]}];

members.locusParticipantsUpdate({
participants: [{id: 'm1', type: 'USER', person: {}, devices}],
});
members.locusParticipantsUpdate({
participants: [{id: 'm1', type: 'USER', person: {}, devices}],
});

assert.strictEqual(members.historyCsisByMemberId.size, 0);
});

it('captures previous CSIs into the map when devices change on update', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000, 1001])],
});
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});

const history = members.historyCsisByMemberId.get('m1');
assert.isDefined(history);
assert.isTrue(history.has(1000));
assert.isTrue(history.has(1001));
});

it('preserves history when a member is removed and re-added (breakout scenario)', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
// change devices so previous CSIs are captured into history
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});
// remove the member
members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

assert.isTrue(members.historyCsisByMemberId.get('m1').has(1000));

// re-add the member with brand new CSIs
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [3000])],
});

const member = members.findMemberByCsi(1000);
assert.isDefined(member);
assert.strictEqual(member.id, 'm1');
});

it("captures the removed member's current CSIs even without a prior device-change update", () => {
// member is added, then removed in the very next delta without any
// intermediate device-change update - their current CSIs must still be
// captured into the history map on removal
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000, 1001])],
});

assert.strictEqual(members.historyCsisByMemberId.size, 0);

members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

const history = members.historyCsisByMemberId.get('m1');
assert.isDefined(history);
assert.isTrue(history.has(1000));
assert.isTrue(history.has(1001));
});

it('merges existing history with CSIs captured at removal time', () => {
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [1000])],
});
// device change captures 1000 into history; member now reports 2000
members.locusParticipantsUpdate({
participants: [participantWithCsis('m1', [2000])],
});
// removal should additionally capture the member's current CSIs (2000)
members.locusParticipantsUpdate({
participants: [],
removedParticipantIds: ['m1'],
});

const history = members.historyCsisByMemberId.get('m1');
assert.isTrue(history.has(1000));
assert.isTrue(history.has(2000));
});
});

describe('getCsisForMember()', () => {
Expand Down
Loading