backend: Implement participant name reservation system
- Added ParticipantNameService to manage unique participant name reservations. - Integrated name reservation in ParticipantService during token generation. - Implemented cleanup of expired name reservations in LivekitWebhookService. - Enhanced RedisService with atomic operations for name reservation. - Updated internal configuration for participant name reservation limits. - Added tests for participant name reservation and release functionality. - Updated frontend dependencies to use the latest version of openvidu-components-angular.
This commit is contained in:
parent
8203be2687
commit
a636ad485f
@ -21,8 +21,6 @@
|
||||
$ref: '../../components/responses/forbidden-error.yaml'
|
||||
'404':
|
||||
$ref: '../../components/responses/error-room-not-found.yaml'
|
||||
'409':
|
||||
$ref: '../../components/responses/internal/error-participant-already-exists.yaml'
|
||||
'422':
|
||||
$ref: '../../components/responses/validation-error.yaml'
|
||||
'500':
|
||||
|
||||
102
backend/package-lock.json
generated
102
backend/package-lock.json
generated
@ -1182,22 +1182,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
|
||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-module-transforms": "^7.27.3",
|
||||
"@babel/helpers": "^7.27.6",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/helper-module-transforms": "^7.28.3",
|
||||
"@babel/helpers": "^7.28.3",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
@ -1223,14 +1223,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@ -1291,15 +1291,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
|
||||
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.3"
|
||||
"@babel/traverse": "^7.28.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -1349,9 +1349,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
|
||||
"integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
|
||||
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1363,13 +1363,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
|
||||
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.0"
|
||||
"@babel/types": "^7.28.2"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -1633,18 +1633,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
|
||||
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.2",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -2326,9 +2326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/confirm": {
|
||||
"version": "5.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz",
|
||||
"integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==",
|
||||
"version": "5.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.15.tgz",
|
||||
"integrity": "sha512-SwHMGa8Z47LawQN0rog0sT+6JpiL0B7eW9p1Bb7iCeKDGTI5Ez25TSc2l8kw52VV7hA4sX/C78CGkMrKXfuspA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2548,14 +2548,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/prompts": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.2.tgz",
|
||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.3.tgz",
|
||||
"integrity": "sha512-iHYp+JCaCRktM/ESZdpHI51yqsDgXu+dMs4semzETftOaF8u5hwlqnbIsuIR/LrWZl8Pm1/gzteK9I7MAq5HTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^4.2.1",
|
||||
"@inquirer/confirm": "^5.1.14",
|
||||
"@inquirer/confirm": "^5.1.15",
|
||||
"@inquirer/editor": "^4.2.17",
|
||||
"@inquirer/expand": "^4.0.17",
|
||||
"@inquirer/input": "^4.2.1",
|
||||
@ -2739,9 +2739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -3648,9 +3648,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.39.3",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.39.3.tgz",
|
||||
"integrity": "sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==",
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.40.0.tgz",
|
||||
"integrity": "sha512-1q0TNqlSTDW9ZuQCYTLDusyO+StvAXPmECQgyuszThDjzhTwQOkA6Rv7B1wPcvpTcwieIG0vuAO4cw4+kg+xWA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@ -7517,9 +7517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.200",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
|
||||
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
|
||||
"version": "1.5.203",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz",
|
||||
"integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -12366,14 +12366,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-generate-html/node_modules/inquirer": {
|
||||
"version": "12.9.2",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.2.tgz",
|
||||
"integrity": "sha512-XPukbomHpZc3GAajQdAcuqa5NCIFhUcLMcXXSpJLM2RW/u/5JHLxjLF206GNTJARib8XBBRqyMbaNrDzXROdoA==",
|
||||
"version": "12.9.3",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.3.tgz",
|
||||
"integrity": "sha512-Hpw2JWdrYY8xJSmhU05Idd5FPshQ1CZErH00WO+FK6fKxkBeqj+E+yFXSlERZLKtzWeQYFCMfl8U2TK9SvVbtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.1.15",
|
||||
"@inquirer/prompts": "^7.8.2",
|
||||
"@inquirer/prompts": "^7.8.3",
|
||||
"@inquirer/type": "^3.0.8",
|
||||
"ansi-escapes": "^4.3.2",
|
||||
"mute-stream": "^2.0.0",
|
||||
|
||||
@ -24,7 +24,8 @@ import {
|
||||
TaskSchedulerService,
|
||||
TokenService,
|
||||
UserService,
|
||||
FrontendEventService
|
||||
FrontendEventService,
|
||||
ParticipantNameService
|
||||
} from '../services/index.js';
|
||||
|
||||
export const container: Container = new Container();
|
||||
@ -61,6 +62,7 @@ export const registerDependencies = () => {
|
||||
container.bind(FrontendEventService).toSelf().inSingletonScope();
|
||||
container.bind(LiveKitService).toSelf().inSingletonScope();
|
||||
container.bind(RoomService).toSelf().inSingletonScope();
|
||||
container.bind(ParticipantNameService).toSelf().inSingletonScope();
|
||||
container.bind(ParticipantService).toSelf().inSingletonScope();
|
||||
container.bind(RecordingService).toSelf().inSingletonScope();
|
||||
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
|
||||
|
||||
@ -17,6 +17,10 @@ const INTERNAL_CONFIG = {
|
||||
PARTICIPANT_TOKEN_EXPIRATION: '2h',
|
||||
RECORDING_TOKEN_EXPIRATION: '2h',
|
||||
|
||||
// Participant name reservations
|
||||
PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed
|
||||
PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations
|
||||
|
||||
// Headers for API requests
|
||||
API_KEY_HEADER: 'x-api-key',
|
||||
PARTICIPANT_ROLE_HEADER: 'x-participant-role',
|
||||
|
||||
@ -44,6 +44,9 @@ export const lkWebhookHandler = async (req: Request, res: Response) => {
|
||||
case 'participant_joined':
|
||||
await lkWebhookService.handleParticipantJoined(room!, participant!);
|
||||
break;
|
||||
case 'participant_left':
|
||||
await lkWebhookService.handleParticipantLeft(room!, participant!);
|
||||
break;
|
||||
case 'room_started':
|
||||
await lkWebhookService.handleRoomStarted(room!);
|
||||
break;
|
||||
|
||||
@ -10,6 +10,11 @@ export const enum RedisKeyName {
|
||||
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
|
||||
USER = `${RedisKeyPrefix.BASE}user:`,
|
||||
API_KEYS = `${RedisKeyPrefix.BASE}api_keys:`,
|
||||
//Tracks all currently reserved participant names per room (with TTL for auto-expiration).
|
||||
ROOM_PARTICIPANTS = `${RedisKeyPrefix.BASE}room_participants:`,
|
||||
// Stores released numeric suffixes (per base name) in a sorted set, so that freed numbers
|
||||
// can be reused efficiently instead of always incrementing to the next highest number.
|
||||
PARTICIPANT_NAME_POOL = `${RedisKeyPrefix.BASE}participant_pool:`
|
||||
}
|
||||
|
||||
export const enum RedisLockPrefix {
|
||||
|
||||
@ -13,6 +13,7 @@ export * from './auth.service.js';
|
||||
export * from './livekit.service.js';
|
||||
export * from './frontend-event.service.js';
|
||||
export * from './room.service.js';
|
||||
export * from './participant-name.service.js';
|
||||
export * from './participant.service.js';
|
||||
export * from './recording.service.js';
|
||||
export * from './openvidu-webhook.service.js';
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
MeetStorageService,
|
||||
MutexService,
|
||||
OpenViduWebhookService,
|
||||
ParticipantService,
|
||||
RecordingService,
|
||||
RoomService,
|
||||
DistributedEventService
|
||||
@ -29,6 +30,7 @@ export class LivekitWebhookService {
|
||||
@inject(MutexService) protected mutexService: MutexService,
|
||||
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
|
||||
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
||||
@inject(ParticipantService) protected participantService: ParticipantService,
|
||||
@inject(LoggerService) protected logger: LoggerService
|
||||
) {
|
||||
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||
@ -158,6 +160,25 @@ export class LivekitWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'participant_left' event by releasing the participant's reserved name
|
||||
* to make it available for other participants.
|
||||
* @param room - Information about the room where the participant left.
|
||||
* @param participant - Information about the participant who left.
|
||||
*/
|
||||
async handleParticipantLeft(room: Room, participant: ParticipantInfo) {
|
||||
// Skip if the participant is an egress participant
|
||||
if (this.livekitService.isEgressParticipant(participant)) return;
|
||||
|
||||
try {
|
||||
// Release the participant's reserved name
|
||||
await this.participantService.releaseParticipantName(room.name, participant.name);
|
||||
this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`);
|
||||
} catch (error) {
|
||||
this.logger.error('Error releasing participant name on participant left:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a room started event from LiveKit.
|
||||
*
|
||||
@ -210,7 +231,10 @@ export class LivekitWebhookService {
|
||||
tasks.push(this.roomService.bulkDeleteRooms([roomName], true));
|
||||
}
|
||||
|
||||
tasks.push(this.recordingService.releaseRecordingLockIfNoEgress(roomName));
|
||||
tasks.push(
|
||||
this.participantService.cleanupParticipantNames(roomName),
|
||||
this.recordingService.releaseRecordingLockIfNoEgress(roomName)
|
||||
);
|
||||
await Promise.all(tasks);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling room finished event: ${error}`);
|
||||
|
||||
314
backend/src/services/participant-name.service.ts
Normal file
314
backend/src/services/participant-name.service.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { RedisKeyName } from '../models/redis.model.js';
|
||||
import { LoggerService, RedisService } from './index.js';
|
||||
import ms from 'ms';
|
||||
import INTERNAL_CONFIG from '../config/internal-config.js';
|
||||
|
||||
@injectable()
|
||||
export class ParticipantNameService {
|
||||
private readonly MAX_CONCURRENT_NAME_REQUESTS = Number(INTERNAL_CONFIG.PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS);
|
||||
private readonly PARTICIPANT_NAME_TTL = ms(INTERNAL_CONFIG.PARTICIPANT_NAME_RESERVATION_TTL);
|
||||
|
||||
constructor(
|
||||
@inject(RedisService) protected redisService: RedisService,
|
||||
@inject(LoggerService) protected logger: LoggerService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reserves a unique participant name for a room using atomic operations.
|
||||
* If the requested name is taken, it generates alternatives with incremental suffixes.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @param requestedName - The desired participant name
|
||||
* @returns Promise<string> - The reserved unique name
|
||||
* @throws Error if unable to reserve a unique name after max retries
|
||||
*/
|
||||
async reserveUniqueName(roomId: string, requestedName: string): Promise<string> {
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
|
||||
// Normalize the base name for case-insensitive comparisons
|
||||
const normalizedBaseName = requestedName.toLowerCase();
|
||||
|
||||
// First, try to reserve the exact requested name
|
||||
const reservedOriginal = await this.tryReserveName(participantsKey, normalizedBaseName);
|
||||
|
||||
if (reservedOriginal) {
|
||||
this.logger.verbose(`Reserved original name '${requestedName}' for room '${roomId}'`);
|
||||
return requestedName;
|
||||
}
|
||||
|
||||
// If original name is taken, generate alternatives with atomic counter
|
||||
for (let attempt = 1; attempt <= this.MAX_CONCURRENT_NAME_REQUESTS; attempt++) {
|
||||
const alternativeName = await this.generateAlternativeName(roomId, normalizedBaseName, attempt);
|
||||
const reserved = await this.tryReserveName(participantsKey, alternativeName);
|
||||
|
||||
if (reserved) {
|
||||
this.logger.verbose(
|
||||
`Reserved alternative name '${alternativeName}' for room '${roomId}' (attempt ${attempt})`
|
||||
);
|
||||
// Return alternative name with original case
|
||||
const suffix = alternativeName.replace(`${normalizedBaseName}_`, '');
|
||||
return `${requestedName}_${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to reserve unique name for '${requestedName}' in room '${roomId}' after ${this.MAX_CONCURRENT_NAME_REQUESTS} attempts`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a reserved participant name, making it available for others.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @param participantName - The name to release
|
||||
*/
|
||||
/**
|
||||
* Releases a reserved participant name, making it available for others.
|
||||
* Also returns the number suffix to the available pool for reuse.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @param participantName - The name to release
|
||||
*/
|
||||
async releaseName(roomId: string, participantName: string): Promise<void> {
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
// Normalize the name for case-insensitive checks
|
||||
const normalizedName = participantName.toLowerCase();
|
||||
|
||||
try {
|
||||
await this.redisService.delete(`${participantsKey}:${normalizedName}`);
|
||||
|
||||
// If this is a numbered variant (e.g., "Alice_2"), return the number to the pool
|
||||
const numberMatch = participantName.match(/^(.+)_(\d+)$/);
|
||||
|
||||
if (numberMatch) {
|
||||
const baseName = numberMatch[1];
|
||||
const number = parseInt(numberMatch[2], 10);
|
||||
await this.returnNumberToPool(roomId, baseName, number);
|
||||
}
|
||||
|
||||
this.logger.verbose(`Released name '${participantName}' for room '${roomId}'`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error releasing name '${participantName}' for room '${roomId}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a participant name is currently reserved in a room.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @param participantName - The name to check
|
||||
* @returns Promise<boolean> - True if the name is reserved
|
||||
*/
|
||||
async isNameReserved(roomId: string, participantName: string): Promise<boolean> {
|
||||
// Normalize the name for case-insensitive checks
|
||||
const normalizedName = participantName.toLowerCase();
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
return await this.redisService.exists(`${participantsKey}:${normalizedName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all currently reserved names in a room.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @returns Promise<string[]> - Array of reserved participant names
|
||||
*/
|
||||
async getReservedNames(roomId: string): Promise<string[]> {
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
const pattern = `${participantsKey}:*`;
|
||||
|
||||
try {
|
||||
const keys = await this.redisService.getKeys(pattern);
|
||||
return keys.map((key) => key.replace(`${participantsKey}:`, ''));
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting reserved names for room '${roomId}':`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up expired participant reservations for a room.
|
||||
* This should be called periodically or when a room is cleaned up.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
*/
|
||||
async cleanupExpiredReservations(roomId: string): Promise<void> {
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
const participantsPoolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}`;
|
||||
const pattern = `${participantsKey}:*`;
|
||||
const poolPattern = `${participantsPoolKey}:*`;
|
||||
|
||||
try {
|
||||
const [participantKeys, poolKeys] = await Promise.all([
|
||||
this.redisService.getKeys(pattern),
|
||||
this.redisService.getKeys(poolPattern)
|
||||
]);
|
||||
this.logger.verbose(
|
||||
`Found ${participantKeys.length} participant reservations to check for room '${roomId}'`
|
||||
);
|
||||
|
||||
// Redis TTL will automatically clean up expired keys, but we can force cleanup if needed
|
||||
const promises = participantKeys.map((key) => this.redisService.delete(key));
|
||||
await Promise.all(promises);
|
||||
this.logger.verbose(
|
||||
`Cleaned up ${participantKeys.length} expired participant names reservations for room '${roomId}'`
|
||||
);
|
||||
|
||||
// Clean up expired participant name numbers from the pool
|
||||
this.logger.verbose(`Found ${poolKeys.length} participant name numbers to check for room '${roomId}'`);
|
||||
const poolPromises = poolKeys.map((key) => this.redisService.delete(key));
|
||||
await Promise.all(poolPromises);
|
||||
this.logger.verbose(`Cleaned up ${poolKeys.length} expired participant name numbers for room '${roomId}'`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error cleaning up reservations for room '${roomId}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to atomically reserve a specific name using Redis SET with NX (not exists) option.
|
||||
*
|
||||
* @private
|
||||
* @param participantsKey - The Redis key prefix for participants
|
||||
* @param name - The name to reserve
|
||||
* @returns Promise<boolean> - True if reservation was successful
|
||||
*/
|
||||
private async tryReserveName(participantsKey: string, name: string): Promise<boolean> {
|
||||
// Normalize the name for case-insensitive checks
|
||||
const normalizedName = name.toLowerCase();
|
||||
const nameKey = `${participantsKey}:${normalizedName}`;
|
||||
const timestamp = Date.now().toString();
|
||||
|
||||
try {
|
||||
return await this.redisService.setIfNotExists(nameKey, timestamp, this.PARTICIPANT_NAME_TTL);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error trying to reserve name '${name}':`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an alternative name using a pool of available numbers.
|
||||
* First tries to get a number from the available pool, then generates the next sequential number.
|
||||
*
|
||||
* @private
|
||||
* @param roomId - The room identifier
|
||||
* @param baseName - The base name to append number to
|
||||
* @param fallbackSuffix - Fallback suffix if Redis fails
|
||||
* @returns Promise<string> - The generated alternative name
|
||||
*/
|
||||
private async generateAlternativeName(roomId: string, baseName: string, fallbackSuffix: number): Promise<string> {
|
||||
try {
|
||||
// Normalize the base name for case-insensitive checks
|
||||
const normalizedBaseName = baseName.toLowerCase();
|
||||
|
||||
// First try to get an available number from the pool
|
||||
const availableNumber = await this.getNumberFromPool(roomId, normalizedBaseName);
|
||||
|
||||
if (availableNumber !== null) {
|
||||
return `${baseName}_${availableNumber}`;
|
||||
}
|
||||
|
||||
// If no number available in pool, find the next sequential number
|
||||
const nextNumber = await this.findNextAvailableNumber(roomId, baseName);
|
||||
return `${baseName}_${nextNumber}`;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error generating alternative name, using fallback:`, error);
|
||||
// Fallback to simple incremental suffix if Redis fails
|
||||
return `${baseName}_${fallbackSuffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the smallest available number from the pool for reuse.
|
||||
*
|
||||
* @private
|
||||
* @param roomId - The room identifier
|
||||
* @param baseName - The base name
|
||||
* @returns Promise<number | null> - Available number or null if pool is empty
|
||||
*/
|
||||
private async getNumberFromPool(roomId: string, baseName: string): Promise<number | null> {
|
||||
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
|
||||
|
||||
try {
|
||||
// Get the smallest number from the sorted set and remove it atomically
|
||||
const results = await this.redisService.popMinFromSortedSet(poolKey, 1);
|
||||
|
||||
if (results.length > 0) {
|
||||
const number = parseInt(results[0], 10);
|
||||
this.logger.verbose(`Reusing number ${number} from pool for '${baseName}' in room '${roomId}'`);
|
||||
return number;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error getting number from pool:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next available sequential number by checking existing participants.
|
||||
*
|
||||
* @private
|
||||
* @param roomId - The room identifier
|
||||
* @param baseName - The base name
|
||||
* @returns Promise<number> - The next available number
|
||||
*/
|
||||
private async findNextAvailableNumber(roomId: string, baseName: string): Promise<number> {
|
||||
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
|
||||
const pattern = `${participantsKey}:${baseName}_*`;
|
||||
|
||||
try {
|
||||
const existingKeys = await this.redisService.getKeys(pattern);
|
||||
const usedNumbers = new Set<number>();
|
||||
|
||||
// Extract all used numbers
|
||||
for (const key of existingKeys) {
|
||||
const name = key.replace(`${participantsKey}:`, '');
|
||||
const numberMatch = name.match(/^.+_(\d+)$/);
|
||||
|
||||
if (numberMatch) {
|
||||
usedNumbers.add(parseInt(numberMatch[1], 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Find the smallest available number starting from 1
|
||||
let nextNumber = 1;
|
||||
|
||||
while (usedNumbers.has(nextNumber)) {
|
||||
nextNumber++;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Generated new sequential number ${nextNumber} for '${baseName}' in room '${roomId}'`);
|
||||
return nextNumber;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error finding next available number:`, error);
|
||||
// Fallback to timestamp-based number if everything fails
|
||||
return Date.now() % 10000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a number to the available pool for reuse.
|
||||
*
|
||||
* @private
|
||||
* @param roomId - The room identifier
|
||||
* @param baseName - The base name
|
||||
* @param number - The number to return to pool
|
||||
*/
|
||||
private async returnNumberToPool(roomId: string, baseName: string, number: number): Promise<void> {
|
||||
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
|
||||
|
||||
try {
|
||||
// Add number to sorted set (score = number for natural ordering)
|
||||
await this.redisService.addToSortedSet(poolKey, number, number.toString());
|
||||
|
||||
// Set TTL on pool key to prevent memory leaks
|
||||
await this.redisService.setExpiration(poolKey, this.PARTICIPANT_NAME_TTL);
|
||||
|
||||
this.logger.verbose(`Returned number ${number} to pool for '${baseName}' in room '${roomId}'`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error returning number to pool:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,11 +10,17 @@ import { ParticipantInfo } from 'livekit-server-sdk';
|
||||
import { MeetRoomHelper } from '../helpers/room.helper.js';
|
||||
import { validateMeetTokenMetadata } from '../middlewares/index.js';
|
||||
import {
|
||||
errorParticipantAlreadyExists,
|
||||
errorParticipantIdentityNotProvided,
|
||||
errorParticipantNotFound
|
||||
} from '../models/error.model.js';
|
||||
import { FrontendEventService, LiveKitService, LoggerService, RoomService, TokenService } from './index.js';
|
||||
import {
|
||||
FrontendEventService,
|
||||
LiveKitService,
|
||||
LoggerService,
|
||||
ParticipantNameService,
|
||||
RoomService,
|
||||
TokenService
|
||||
} from './index.js';
|
||||
|
||||
@injectable()
|
||||
export class ParticipantService {
|
||||
@ -23,7 +29,8 @@ export class ParticipantService {
|
||||
@inject(RoomService) protected roomService: RoomService,
|
||||
@inject(LiveKitService) protected livekitService: LiveKitService,
|
||||
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
||||
@inject(TokenService) protected tokenService: TokenService
|
||||
@inject(TokenService) protected tokenService: TokenService,
|
||||
@inject(ParticipantNameService) protected participantNameService: ParticipantNameService
|
||||
) {}
|
||||
|
||||
async generateOrRefreshParticipantToken(
|
||||
@ -32,34 +39,49 @@ export class ParticipantService {
|
||||
refresh = false
|
||||
): Promise<string> {
|
||||
const { roomId, secret, participantName, participantIdentity } = participantOptions;
|
||||
let finalParticipantName = participantName;
|
||||
let finalParticipantOptions: ParticipantOptions = participantOptions;
|
||||
|
||||
if (participantName) {
|
||||
if (!refresh) {
|
||||
// Check if participant with same participantName exists in the room
|
||||
const participantExists = await this.participantExists(roomId, participantName, 'name');
|
||||
|
||||
if (participantExists) {
|
||||
this.logger.verbose(`Participant '${participantName}' already exists in room '${roomId}'`);
|
||||
throw errorParticipantAlreadyExists(participantName, roomId);
|
||||
}
|
||||
} else {
|
||||
if (refresh) {
|
||||
if (!participantIdentity) {
|
||||
throw errorParticipantIdentityNotProvided();
|
||||
}
|
||||
|
||||
this.logger.verbose(`Refreshing participant token for '${participantIdentity}' in room '${roomId}'`);
|
||||
// Check if participant with same participantIdentity exists in the room
|
||||
const participantExists = await this.participantExists(roomId, participantIdentity, 'identity');
|
||||
|
||||
if (!participantExists) {
|
||||
this.logger.verbose(`Participant '${participantName}' does not exist in room '${roomId}'`);
|
||||
throw errorParticipantNotFound(participantName, roomId);
|
||||
this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`);
|
||||
throw errorParticipantNotFound(participantIdentity, roomId);
|
||||
}
|
||||
} else {
|
||||
this.logger.verbose(`Generating participant token for '${participantName}' in room '${roomId}'`);
|
||||
|
||||
try {
|
||||
// Reserve a unique name for the participant
|
||||
finalParticipantName = await this.participantNameService.reserveUniqueName(roomId, participantName);
|
||||
this.logger.verbose(`Reserved unique name '${finalParticipantName}' for room '${roomId}'`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to reserve unique name '${participantName}' for room '${roomId}':`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Update participantOptions with the final participant name
|
||||
finalParticipantOptions = {
|
||||
...participantOptions,
|
||||
participantName: finalParticipantName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
|
||||
const token = await this.generateParticipantToken(participantOptions, role, currentRoles);
|
||||
this.logger.verbose(`Participant token generated for room '${roomId}'`);
|
||||
const token = await this.generateParticipantToken(finalParticipantOptions, role, currentRoles);
|
||||
this.logger.verbose(`Participant token generated for room '${roomId}' with name '${finalParticipantName}'`);
|
||||
return token;
|
||||
}
|
||||
|
||||
@ -186,4 +208,41 @@ export class ParticipantService {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a participant's reserved name when they disconnect.
|
||||
* This should be called when a participant leaves the room to free up the name.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @param participantName - The participant name to release
|
||||
*/
|
||||
async releaseParticipantName(roomId: string, participantName: string): Promise<void> {
|
||||
try {
|
||||
await this.participantNameService.releaseName(roomId, participantName);
|
||||
this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all currently reserved participant names in a room.
|
||||
* Useful for debugging and monitoring.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
* @returns Promise<string[]> - Array of reserved participant names
|
||||
*/
|
||||
async getReservedNames(roomId: string): Promise<string[]> {
|
||||
return await this.participantNameService.getReservedNames(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up expired participant name reservations for a room.
|
||||
* This can be called during room cleanup or periodically.
|
||||
*
|
||||
* @param roomId - The room identifier
|
||||
*/
|
||||
async cleanupParticipantNames(roomId: string): Promise<void> {
|
||||
await this.participantNameService.cleanupExpiredReservations(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,18 +206,26 @@ export class RedisService extends EventEmitter {
|
||||
* @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
|
||||
* @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
|
||||
*/
|
||||
async set(key: string, value: any, withTTL = true): Promise<string> {
|
||||
async set(key: string, value: string | number | boolean | object, withTTL = true): Promise<string> {
|
||||
try {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
|
||||
if (valueType === 'string' || valueType === 'number') {
|
||||
if (withTTL) {
|
||||
await this.redisPublisher.set(key, value, 'EX', this.DEFAULT_TTL);
|
||||
await this.redisPublisher.set(key, value.toString(), 'EX', this.DEFAULT_TTL);
|
||||
} else {
|
||||
await this.redisPublisher.set(key, value);
|
||||
await this.redisPublisher.set(key, value.toString());
|
||||
}
|
||||
} else if (valueType === 'boolean') {
|
||||
const stringValue = value.toString();
|
||||
|
||||
if (withTTL) {
|
||||
await this.redisPublisher.set(key, stringValue, 'EX', this.DEFAULT_TTL);
|
||||
} else {
|
||||
await this.redisPublisher.set(key, stringValue);
|
||||
}
|
||||
} else if (valueType === 'object') {
|
||||
await this.redisPublisher.hmset(key, value);
|
||||
await this.redisPublisher.hmset(key, value as Record<string, string | number>);
|
||||
|
||||
if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL);
|
||||
} else {
|
||||
@ -250,6 +258,81 @@ export class RedisService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically sets a value only if the key doesn't exist (SET with NX option).
|
||||
*
|
||||
* @param {string} key - The key to set
|
||||
* @param {string} value - The value to set
|
||||
* @param {number} [ttlSeconds] - Optional TTL in seconds
|
||||
* @returns {Promise<boolean>} - True if the key was set, false if it already existed
|
||||
*/
|
||||
async setIfNotExists(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||
try {
|
||||
let result: string | null;
|
||||
|
||||
if (ttlSeconds) {
|
||||
result = await this.redisPublisher.set(key, value, 'EX', ttlSeconds, 'NX');
|
||||
} else {
|
||||
result = (await this.redisPublisher.setnx(key, value)) ? 'OK' : null;
|
||||
}
|
||||
|
||||
return result === 'OK';
|
||||
} catch (error) {
|
||||
this.logger.error('Error setting value with NX option in Redis', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an expiration time on an existing key.
|
||||
*
|
||||
* @param {string} key - The key to set expiration on
|
||||
* @param {number} ttlSeconds - TTL in seconds
|
||||
* @returns {Promise<boolean>} - True if expiration was set successfully
|
||||
*/
|
||||
async setExpiration(key: string, ttlSeconds: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redisPublisher.expire(key, ttlSeconds);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
this.logger.error('Error setting expiration in Redis', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns the member with the lowest score from a sorted set.
|
||||
*
|
||||
* @param {string} key - The key of the sorted set
|
||||
* @param {number} count - Number of members to pop (default: 1)
|
||||
* @returns {Promise<string[]>} - Array of popped members
|
||||
*/
|
||||
async popMinFromSortedSet(key: string, count = 1): Promise<string[]> {
|
||||
try {
|
||||
return await this.redisPublisher.zpopmin(key, count);
|
||||
} catch (error) {
|
||||
this.logger.error('Error popping min from sorted set in Redis', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more members to a sorted set.
|
||||
*
|
||||
* @param {string} key - The key of the sorted set
|
||||
* @param {number} score - The score for the member
|
||||
* @param {string} member - The member to add
|
||||
* @returns {Promise<number>} - Number of elements added
|
||||
*/
|
||||
async addToSortedSet(key: string, score: number, member: string): Promise<number> {
|
||||
try {
|
||||
return await this.redisPublisher.zadd(key, score, member);
|
||||
} catch (error) {
|
||||
this.logger.error('Error adding to sorted set in Redis', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.logger.verbose('Cleaning up Redis connections');
|
||||
this.redisPublisher.quit();
|
||||
|
||||
@ -57,7 +57,7 @@ export class TokenService {
|
||||
if (participantName && !participantIdentity) {
|
||||
// Generate participant identity based on name and unique ID
|
||||
const identityPrefix = participantName.replace(/\s+/g, ''); // Remove all spaces
|
||||
participantIdentity = `${identityPrefix}-${uid(5)}`;
|
||||
participantIdentity = `${identityPrefix}-${uid(8)}`;
|
||||
}
|
||||
|
||||
const metadata: MeetTokenMetadata = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
import { ParticipantRole } from '../../../../src/typings/ce/participant.js';
|
||||
import { expectValidationError, expectValidParticipantTokenResponse } from '../../../helpers/assertion-helpers.js';
|
||||
import {
|
||||
@ -17,10 +17,14 @@ describe('Participant API Tests', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
startTestServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
roomData = await setupSingleRoom();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Force to cleanup participant name reservations after each test
|
||||
afterEach(async () => {
|
||||
await disconnectFakeParticipants();
|
||||
await deleteAllRooms();
|
||||
});
|
||||
@ -87,14 +91,35 @@ describe('Participant API Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail with 409 when participant already exists in the room', async () => {
|
||||
it('should success when participant already exists in the room', async () => {
|
||||
roomData = await setupSingleRoom(true);
|
||||
const response = await generateParticipantToken({
|
||||
let response = await generateParticipantToken({
|
||||
roomId: roomData.room.roomId,
|
||||
secret: roomData.moderatorSecret,
|
||||
participantName
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
// First participant using API. LK CLI participants can reuse the same name.
|
||||
expectValidParticipantTokenResponse(
|
||||
response,
|
||||
roomData.room.roomId,
|
||||
ParticipantRole.MODERATOR,
|
||||
participantName
|
||||
);
|
||||
|
||||
response = await generateParticipantToken({
|
||||
roomId: roomData.room.roomId,
|
||||
secret: roomData.moderatorSecret,
|
||||
participantName
|
||||
});
|
||||
|
||||
// Second participant using API, the participant name should be unique
|
||||
expectValidParticipantTokenResponse(
|
||||
response,
|
||||
roomData.room.roomId,
|
||||
ParticipantRole.MODERATOR,
|
||||
participantName + '_1'
|
||||
);
|
||||
|
||||
// Recreate the room without the participant
|
||||
roomData = await setupSingleRoom();
|
||||
|
||||
283
backend/tests/unit/services/participant-name.service.test.ts
Normal file
283
backend/tests/unit/services/participant-name.service.test.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, beforeAll } from '@jest/globals';
|
||||
import { container, registerDependencies } from '../../../src/config/index.js';
|
||||
import { ParticipantNameService } from '../../../src/services/participant-name.service.js';
|
||||
import { RedisService } from '../../../src/services/redis.service.js';
|
||||
import ms from 'ms';
|
||||
|
||||
describe('ParticipantNameService', () => {
|
||||
let participantNameService: ParticipantNameService;
|
||||
let redisService: RedisService;
|
||||
const testRoomId = 'test-room-unique-names';
|
||||
|
||||
beforeAll(async () => {
|
||||
registerDependencies();
|
||||
participantNameService = container.get(ParticipantNameService);
|
||||
redisService = container.get(RedisService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any existing test data
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data after each test
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
async function cleanupTestData() {
|
||||
try {
|
||||
const pattern = `ov_meet:room_participants:${testRoomId}:*`;
|
||||
const keys = await redisService.getKeys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await redisService.delete(keys);
|
||||
}
|
||||
|
||||
const counterPattern = `ov_meet:participant_counter:${testRoomId}:*`;
|
||||
const counterKeys = await redisService.getKeys(counterPattern);
|
||||
|
||||
if (counterKeys.length > 0) {
|
||||
await redisService.delete(counterKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
describe('Reserve unique participant name', () => {
|
||||
it('should reserve the original name when available', async () => {
|
||||
const requestedName = 'Participant';
|
||||
const reservedName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
|
||||
expect(reservedName).toBe(requestedName);
|
||||
|
||||
// Verify the name is actually reserved
|
||||
const isReserved = await participantNameService.isNameReserved(testRoomId, requestedName);
|
||||
expect(isReserved).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat names as case-insensitive if required', async () => {
|
||||
await participantNameService.reserveUniqueName(testRoomId, 'Participant');
|
||||
const reserved2 = await participantNameService.reserveUniqueName(testRoomId, 'participant');
|
||||
expect(reserved2).toBe('participant_1');
|
||||
});
|
||||
|
||||
it('should generate alternative names when original is taken', async () => {
|
||||
const requestedName = 'Participant';
|
||||
|
||||
// Reserve the original name
|
||||
const firstReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(firstReservation).toBe(requestedName);
|
||||
|
||||
// Try to reserve the same name again - should get alternative
|
||||
const secondReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(secondReservation).toBe(`${requestedName}_1`);
|
||||
|
||||
// Try again - should get next alternative
|
||||
const thirdReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(thirdReservation).toBe(`${requestedName}_2`);
|
||||
});
|
||||
|
||||
it('should handle concurrent reservations properly', async () => {
|
||||
const requestedName = 'Participant';
|
||||
const concurrentRequests = 5;
|
||||
|
||||
// Simulate concurrent requests for the same name
|
||||
const reservationPromises = Array.from({ length: concurrentRequests }, () =>
|
||||
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
||||
);
|
||||
|
||||
const reservedNames = await Promise.all(reservationPromises);
|
||||
|
||||
// All names should be unique
|
||||
const uniqueNames = new Set(reservedNames);
|
||||
expect(uniqueNames.size).toBe(concurrentRequests);
|
||||
|
||||
// First name should be the original
|
||||
expect(reservedNames).toContain(requestedName);
|
||||
|
||||
// Others should be alternatives
|
||||
for (let i = 1; i < concurrentRequests; i++) {
|
||||
expect(reservedNames).toContain(`${requestedName}_${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reuse numbers when participants disconnect', async () => {
|
||||
const requestedName = 'Participant';
|
||||
|
||||
// Reserve multiple names
|
||||
const name1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
const name2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
const name3 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
|
||||
expect(name1).toBe('Participant');
|
||||
expect(name2).toBe('Participant_1');
|
||||
expect(name3).toBe('Participant_2');
|
||||
|
||||
// Release the middle one
|
||||
await participantNameService.releaseName(testRoomId, name2);
|
||||
|
||||
// Next reservation should reuse the released number
|
||||
const name4 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(name4).toBe('Participant_1'); // Should reuse the released number
|
||||
});
|
||||
|
||||
it('should maintain optimal numbering after multiple releases', async () => {
|
||||
const requestedName = 'Optimized';
|
||||
|
||||
// Create several names
|
||||
const names: string[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
names.push(await participantNameService.reserveUniqueName(testRoomId, requestedName));
|
||||
}
|
||||
|
||||
expect(names).toEqual(['Optimized', 'Optimized_1', 'Optimized_2', 'Optimized_3', 'Optimized_4']);
|
||||
|
||||
// Release some names (simulate participants leaving)
|
||||
await participantNameService.releaseName(testRoomId, 'Optimized_1');
|
||||
await participantNameService.releaseName(testRoomId, 'Optimized_3');
|
||||
|
||||
// New participants should get the lowest available numbers
|
||||
const newName1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
const newName2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
|
||||
expect(newName1).toBe('Optimized_1'); // Lowest available
|
||||
expect(newName2).toBe('Optimized_3'); // Next lowest available
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseName', () => {
|
||||
it('should release a reserved name', async () => {
|
||||
const participantName = 'Participant';
|
||||
|
||||
// Reserve a name
|
||||
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(true);
|
||||
|
||||
// Release the name
|
||||
await participantNameService.releaseName(testRoomId, participantName);
|
||||
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow reusing a released name', async () => {
|
||||
const participantName = 'Frank';
|
||||
|
||||
// Reserve, release, and reserve again
|
||||
await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||
await participantNameService.releaseName(testRoomId, participantName);
|
||||
|
||||
const newReservation = await participantNameService.reserveUniqueName(testRoomId, participantName);
|
||||
expect(newReservation).toBe(participantName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReservedNames', () => {
|
||||
it('should return all reserved names in a room in lowercase', async () => {
|
||||
const names = ['Grace', 'Henry', 'Iris'];
|
||||
|
||||
// Reserve multiple names
|
||||
for (const name of names) {
|
||||
await participantNameService.reserveUniqueName(testRoomId, name);
|
||||
}
|
||||
|
||||
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
||||
|
||||
for (const name of names) {
|
||||
expect(reservedNames).toContain(name.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when no names are reserved', async () => {
|
||||
const reservedNames = await participantNameService.getReservedNames(testRoomId);
|
||||
expect(reservedNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reserve unique participant name - edge cases', () => {
|
||||
it('should be able to reserve same 20 names', async () => {
|
||||
const requestedName = 'LimitTest';
|
||||
|
||||
const promises: Promise<string>[] = [];
|
||||
const twentyNames = participantNameService['MAX_CONCURRENT_NAME_REQUESTS'];
|
||||
|
||||
for (let i = 0; i <= twentyNames; i++) {
|
||||
promises.push(participantNameService.reserveUniqueName(testRoomId, requestedName));
|
||||
}
|
||||
|
||||
// Los primeros MAX_RETRIES deben resolverse bien
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled') as PromiseFulfilledResult<string>[];
|
||||
const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
|
||||
|
||||
console.log(fulfilled);
|
||||
expect(fulfilled.length).toBe(twentyNames + 1); // +1 for the original name
|
||||
expect(rejected.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle race condition when reusing released numbers', async () => {
|
||||
const requestedName = 'RaceTest';
|
||||
|
||||
// Try to reserve two names
|
||||
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
const n2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
|
||||
expect([n1, n2]).toEqual(['RaceTest', 'RaceTest_1']);
|
||||
|
||||
// Release _1
|
||||
await participantNameService.releaseName(testRoomId, n2);
|
||||
|
||||
// Try to reserve again concurrently
|
||||
const [c1, c2] = await Promise.all([
|
||||
participantNameService.reserveUniqueName(testRoomId, requestedName),
|
||||
participantNameService.reserveUniqueName(testRoomId, requestedName)
|
||||
]);
|
||||
|
||||
// One of them should be _1 and the other should be _2
|
||||
expect([c1, c2].sort()).toEqual(['RaceTest_1', 'RaceTest_2']);
|
||||
});
|
||||
|
||||
it('should reuse expired names after TTL', async () => {
|
||||
(participantNameService as any)['PARTICIPANT_NAME_TTL'] = ms('1ms');
|
||||
const requestedName = 'TTLTest';
|
||||
|
||||
// Reserva con TTL muy corto (simulado)
|
||||
const name = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(name).toBe('TTLTest');
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, (participantNameService['PARTICIPANT_NAME_TTL'] + 1) * 1000)
|
||||
);
|
||||
|
||||
// Try to reserve again
|
||||
const newName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(newName).toBe('TTLTest'); // Reuse original name
|
||||
});
|
||||
|
||||
it('should keep names isolated per room', async () => {
|
||||
const requestedName = 'Isolated';
|
||||
|
||||
// Reserve in two different rooms
|
||||
const room1Name = await participantNameService.reserveUniqueName('room1', requestedName);
|
||||
const room2Name = await participantNameService.reserveUniqueName('room2', requestedName);
|
||||
|
||||
// Both names should be isolated
|
||||
expect(room1Name).toBe(requestedName);
|
||||
expect(room2Name).toBe(requestedName);
|
||||
});
|
||||
|
||||
it('should treat names case-insensitively if normalization is enabled', async () => {
|
||||
const requestedName = 'CaseTest';
|
||||
|
||||
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
|
||||
expect(n1).toBe('CaseTest');
|
||||
|
||||
// Try to reserve with different casing
|
||||
const n2 = await participantNameService.reserveUniqueName(testRoomId, 'casetest');
|
||||
expect(n2).toBe('casetest_1'); // Should return alternative name with original case
|
||||
});
|
||||
});
|
||||
});
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"core-js": "^3.38.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"livekit-server-sdk": "^2.10.2",
|
||||
"openvidu-components-angular": "^3.4.0-dev14",
|
||||
"openvidu-components-angular": "^3.4.0-dev15",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "^2.3.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
@ -13576,9 +13576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/openvidu-components-angular": {
|
||||
"version": "3.4.0-dev14",
|
||||
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev14.tgz",
|
||||
"integrity": "sha512-pvuQCgDqmweWdEn40x6DtN27A1pRBo4tVAFSAciM27EQtDXsV30k0uqC2khdrZfYeGcK1usvqcOmXEloQ4Aw9Q==",
|
||||
"version": "3.4.0-dev15",
|
||||
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev15.tgz",
|
||||
"integrity": "sha512-rH/jPOx+ZgCIxbKW1+nvEh5qbJR5V6pbWs8SFWd1gV1V7wUJYoFI+iXxO0naaWHifq0rYNO0NoLpz2hjJqPQpQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
"core-js": "^3.38.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"livekit-server-sdk": "^2.10.2",
|
||||
"openvidu-components-angular": "^3.4.0-dev14",
|
||||
"openvidu-components-angular": "^3.4.0-dev15",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "^2.3.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
@if (showMeeting) {
|
||||
<ov-videoconference
|
||||
[token]="participantToken"
|
||||
[participantName]="participantName"
|
||||
|
||||
[prejoin]="true"
|
||||
[prejoinDisplayParticipantName]="false"
|
||||
[videoEnabled]="features().videoEnabled"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user