feat: add event listener for canceling destroy timer

Signed-off-by: yamashush <38120991+yamashush@users.noreply.github.com>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-01-21 18:10:58 +09:00
parent c5d8341c45
commit 956dd28648
2 changed files with 69 additions and 3 deletions

View file

@ -108,4 +108,41 @@ describe('realtime note', () => {
expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage); expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage);
expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage); expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage);
}); });
describe('removeClient', () => {
it('destory if the number of connected clients reaches zero and the lifetime is exceeded', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build();
const docDestroy = jest.spyOn(sut, 'destroy');
sut.addClient(client1);
sut.removeClient(client1);
jest.advanceTimersByTime(5000);
sut.addClient(client1);
sut.removeClient(client1);
jest.advanceTimersByTime(10500);
expect(docDestroy).toHaveBeenCalledTimes(1);
});
it("doesn't destory when a client reconnects quickly", () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = new MockConnectionBuilder(sut).withLoggedInUser().build();
const docDestroy = jest.spyOn(sut, 'destroy');
// Assuming the case where the only connected user reloads the browser
sut.addClient(client1);
sut.removeClient(client1);
jest.advanceTimersByTime(5000);
sut.addClient(client1);
sut.removeClient(client1);
jest.advanceTimersByTime(5000);
sut.addClient(client1);
expect(docDestroy).toHaveBeenCalledTimes(0);
});
});
}); });

View file

@ -5,7 +5,7 @@
*/ */
import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons'; import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { EventEmitter2, EventMap } from 'eventemitter2'; import { EventEmitter2, EventMap, Listener } from 'eventemitter2';
import { Note } from '../../notes/note.entity'; import { Note } from '../../notes/note.entity';
import { RealtimeConnection } from './realtime-connection'; import { RealtimeConnection } from './realtime-connection';
@ -19,6 +19,8 @@ export interface RealtimeNoteEventMap extends EventMap {
yDocUpdate: (update: number[], origin: unknown) => void; yDocUpdate: (update: number[], origin: unknown) => void;
} }
const LIFETIME_WITHOUT_CLIENTS = 10 * 1000; // 10 seconds
/** /**
* Represents a note currently being edited by a number of clients. * Represents a note currently being edited by a number of clients.
*/ */
@ -26,7 +28,9 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
protected logger: Logger; protected logger: Logger;
private readonly doc: RealtimeDoc; private readonly doc: RealtimeDoc;
private readonly clients = new Set<RealtimeConnection>(); private readonly clients = new Set<RealtimeConnection>();
private readonly clientAddedListener: Listener;
private isClosing = false; private isClosing = false;
private destroyEventTimer: NodeJS.Timeout | null = null;
constructor( constructor(
private readonly note: Note, private readonly note: Note,
@ -40,6 +44,18 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
this.logger.debug( this.logger.debug(
`New realtime session for note ${note.id} created. Length of initial content: ${length} characters`, `New realtime session for note ${note.id} created. Length of initial content: ${length} characters`,
); );
this.clientAddedListener = this.on(
'clientAdded',
() => {
if (this.destroyEventTimer) {
clearTimeout(this.destroyEventTimer);
this.destroyEventTimer = null;
}
},
{
objectify: true,
},
) as Listener;
} }
/** /**
@ -68,8 +84,13 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
} clients left.`, } clients left.`,
); );
this.emit('clientRemoved', client); this.emit('clientRemoved', client);
if (!this.hasConnections() && !this.isClosing) {
this.destroy(); if (this.canBeDestroyed()) {
this.destroyEventTimer = setTimeout(() => {
if (this.canBeDestroyed()) {
this.destroy();
}
}, LIFETIME_WITHOUT_CLIENTS);
} }
} }
@ -87,6 +108,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
this.isClosing = true; this.isClosing = true;
this.doc.destroy(); this.doc.destroy();
this.clients.forEach((value) => value.getTransporter().disconnect()); this.clients.forEach((value) => value.getTransporter().disconnect());
this.clientAddedListener.off();
this.emit('destroy'); this.emit('destroy');
} }
@ -150,4 +172,11 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
connection.getTransporter().sendMessage(content); connection.getTransporter().sendMessage(content);
}); });
} }
/**
* Indicates if a realtime note is ready to get destroyed.
*/
private canBeDestroyed(): boolean {
return !this.hasConnections() && !this.isClosing;
}
} }