Skip to content

Room List - Store sorted rooms in skip list #29345

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

Merged
merged 9 commits into from
Feb 25, 2025
114 changes: 114 additions & 0 deletions src/stores/room-list-v3/skip-list/Level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { RoomNode } from "./RoomNode";
import { shouldPromote } from "./utils";

/**
* Represents one level of the skip list
*/
export class Level {
public head?: RoomNode;
private current?: RoomNode;
private _size: number = 0;

/**
* The number of elements in this level
*/
public get size(): number {
return this._size;
}

public constructor(public readonly level: number) {}

/**
* Insert node after current
*/
public setNext(node: RoomNode): void {
if (!this.head) this.head = node;
if (!this.current) {
this.current = node;
} else {
node.previous[this.level] = this.current;
this.current.next[this.level] = node;
this.current = node;
}
this._size++;
}

/**
* Iterate through the elements in this level and create
* a new level above this level by probabilistically determining
* whether a given element must be promoted to the new level.
*/
public generateNextLevel(): Level {
const nextLevelSentinel = new Level(this.level + 1);
let current = this.head;
while (current) {
if (shouldPromote()) {
nextLevelSentinel.setNext(current);
}
current = current.next[this.level];
}
return nextLevelSentinel;
}

/**
* Removes a given node from this level.
* Does nothing if the given node is not present in this level.
*/
public removeNode(node: RoomNode): void {
// Let's first see if this node is even in this level
const nodeInThisLevel = this.head === node || node.previous[this.level];
if (!nodeInThisLevel) {
// This node is not in this sentinel level, so nothing to do.
return;
}
const prev = node.previous[this.level];
if (prev) {
const nextNode = node.next[this.level];
prev.next[this.level] = nextNode;
if (nextNode) nextNode.previous[this.level] = prev;
} else {
// This node was the head since it has no back links!
// so update the head.
const next = node.next[this.level];
this.head = next;
if (next) next.previous[this.level] = node.previous[this.level];
}
this._size--;
}

/**
* Put newNode after node in this level. No checks are done to ensure
* that node is actually present in this level.
*/
public insertAfter(node: RoomNode, newNode: RoomNode): void {
const level = this.level;
const nextNode = node.next[level];
if (nextNode) {
newNode.next[level] = nextNode;
nextNode.previous[level] = newNode;
}
node.next[level] = newNode;
newNode.previous[level] = node;
this._size++;
}

/**
* Insert a given node at the head of this level.
*/
public insertAtHead(newNode: RoomNode): void {
const existingNode = this.head;
this.head = newNode;
if (existingNode) {
newNode.next[this.level] = existingNode;
existingNode.previous[this.level] = newNode;
}
this._size++;
}
}
29 changes: 29 additions & 0 deletions src/stores/room-list-v3/skip-list/RoomNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Room } from "matrix-js-sdk/src/matrix";

/**
* Room skip list stores room nodes.
* These hold the actual room object and provides references to other nodes
* in different levels.
*/
export class RoomNode {
public constructor(public readonly room: Room) {}

/**
* This array holds references to the next node in a given level.
* eg: next[i] gives the next room node from this room node in level i.
*/
public next: RoomNode[] = [];

/**
* This array holds references to the previous node in a given level.
* eg: previous[i] gives the previous room node from this room node in level i.
*/
public previous: RoomNode[] = [];
}
181 changes: 181 additions & 0 deletions src/stores/room-list-v3/skip-list/RoomSkipList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Room } from "matrix-js-sdk/src/matrix";
import type { Sorter } from "./sorters";
import { RoomNode } from "./RoomNode";
import { shouldPromote } from "./utils";
import { Level } from "./Level";

/**
* Implements a skip list that stores rooms using a given sorting algorithm.
* See See https://en.wikipedia.org/wiki/Skip_list
*/
export class RoomSkipList implements Iterable<Room> {
private levels: Level[] = [new Level(0)];
private roomNodeMap: Map<string, RoomNode> = new Map();
public initialized: boolean = false;

public constructor(private sorter: Sorter) {}

private reset(): void {
this.levels = [new Level(0)];
this.roomNodeMap = new Map();
}

/**
* Seed the list with an initial list of rooms.
*/
public seed(rooms: Room[]): void {
// 1. First sort the rooms and create a base sorted linked list
const sortedRoomNodes = this.sorter.sort(rooms).map((room) => new RoomNode(room));
let currentLevel = this.levels[0];
for (const node of sortedRoomNodes) {
currentLevel.setNext(node);
this.roomNodeMap.set(node.room.roomId, node);
}

// 2. Create the rest of the sub linked lists
do {
this.levels[currentLevel.level] = currentLevel;
currentLevel = currentLevel.generateNextLevel();
} while (currentLevel.size > 1);
this.initialized = true;
}

/**
* Change the sorting algorithm used by the skip list.
* This will reset the list and will rebuild from scratch.
*/
public useNewSorter(sorter: Sorter, rooms: Room[]): void {
this.reset();
this.sorter = sorter;
this.seed(rooms);
}

/**
* Removes a given room from the skip list.
*/
public removeRoom(room: Room): void {
const existingNode = this.roomNodeMap.get(room.roomId);
this.roomNodeMap.delete(room.roomId);
if (existingNode) {
for (const level of this.levels) {
level.removeNode(existingNode);
}
}
}

/**
* Adds a given room to the correct sorted position in the list.
* If the room is already present in the list, it is first removed.
*/
public addRoom(room: Room): void {
/**
* Remove this room from the skip list if necessary.
*/
this.removeRoom(room);

const newNode = new RoomNode(room);
this.roomNodeMap.set(room.roomId, newNode);

/**
* This array tracks where the new node must be inserted in a
* given level.
* The index is the level and the value represents where the
* insertion must happen.
* If the value is null, it simply means that we need to insert
* at the head.
* If the value is a RoomNode, simply insert after this node.
*/
const insertionNodes: (RoomNode | null)[] = [];

/**
* Now we'll do the actual work of finding where to insert this
* node.
*
* We start at the top most level and move downwards ...
*/
for (let j = this.levels.length - 1; j >= 0; --j) {
const level = this.levels[j];

/**
* If the head is undefined, that means this level is empty.
* So mark it as such in insertionNodes and skip over this
* level.
*/
if (!level.head) {
insertionNodes[j] = null;
continue;
}

/**
* So there's actually some nodes in this level ...
* All we need to do is find the node that is smaller or
* equal to the node that we wish to insert.
*/
let current = level.head;
let previous: RoomNode | null = null;
while (current) {
if (this.sorter.comparator(current.room, room) < 0) {
previous = current;
current = current.next[j];
} else break;
}

/**
* previous will now be null if there's no node in this level
* smaller than the node we wish to insert or it will be a
* RoomNode.
* This is exactly what we need to track in insertionNodes!
*/
insertionNodes[j] = previous;
}

/**
* We're done with difficult part, now we just need to do the
* actual node insertion.
*/
for (const [level, node] of insertionNodes.entries()) {
/**
* Whether our new node should be present in a level
* is decided by coin toss.
*/
if (level === 0 || shouldPromote()) {
const levelObj = this.levels[level];
if (node) levelObj.insertAfter(node, newNode);
else levelObj.insertAtHead(newNode);
} else {
break;
}
}
}

public [Symbol.iterator](): SortedRoomIterator {
return new SortedRoomIterator(this.levels[0].head!);
}

/**
* The number of rooms currently in the skip list.
*/
public get size(): number {
return this.levels[0].size;
}
}

class SortedRoomIterator implements Iterator<Room> {
public constructor(private current: RoomNode) {}

public next(): IteratorResult<Room> {
const current = this.current;
if (!current) return { value: undefined, done: true };
this.current = current.next[0];
return {
value: current.room,
};
}
}
23 changes: 23 additions & 0 deletions src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Room } from "matrix-js-sdk/src/matrix";
import type { Sorter } from ".";

export class AlphabeticSorter implements Sorter {
private readonly collator = new Intl.Collator();

public sort(rooms: Room[]): Room[] {
return [...rooms].sort((a, b) => {
return this.comparator(a, b);
});
}

public comparator(roomA: Room, roomB: Room): number {
return this.collator.compare(roomA.name, roomB.name);
}
}
33 changes: 33 additions & 0 deletions src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Room } from "matrix-js-sdk/src/matrix";
import type { Sorter } from ".";
import { getLastTs } from "../../../room-list/algorithms/tag-sorting/RecentAlgorithm";

export class RecencySorter implements Sorter {
public constructor(private myUserId: string) {}

public sort(rooms: Room[]): Room[] {
const tsCache: { [roomId: string]: number } = {};
return [...rooms].sort((a, b) => this.comparator(a, b, tsCache));
}

public comparator(roomA: Room, roomB: Room, cache?: any): number {
const roomALastTs = this.getTs(roomA, cache);
const roomBLastTs = this.getTs(roomB, cache);
return roomBLastTs - roomALastTs;
}

private getTs(room: Room, cache?: { [roomId: string]: number }): number {
const ts = cache?.[room.roomId] ?? getLastTs(room, this.myUserId);
if (cache) {
cache[room.roomId] = ts;
}
return ts;
}
}
13 changes: 13 additions & 0 deletions src/stores/room-list-v3/skip-list/sorters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Room } from "matrix-js-sdk/src/matrix";

export interface Sorter {
sort(rooms: Room[]): Room[];
comparator(roomA: Room, roomB: Room): number;
}
Loading
Loading