backend: update recording start method for improving its clarity and avoding race conditions receiving events
This commit is contained in:
parent
8c6011b5c3
commit
9897436df2
@ -152,7 +152,7 @@ export class LivekitWebhookService {
|
|||||||
try {
|
try {
|
||||||
const [meetRoom] = await Promise.all([
|
const [meetRoom] = await Promise.all([
|
||||||
this.roomService.getMeetRoom(room.name),
|
this.roomService.getMeetRoom(room.name),
|
||||||
this.recordingService.releaseRoomRecordingActiveLock(room.name),
|
this.recordingService.releaseRecordingLockIfNoEgress(room.name),
|
||||||
this.openViduWebhookService.sendRoomFinishedWebhook(room)
|
this.openViduWebhookService.sendRoomFinishedWebhook(room)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -223,7 +223,7 @@ export class LivekitWebhookService {
|
|||||||
case 'ended':
|
case 'ended':
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.openViduWebhookService.sendRecordingEndedWebhook(recordingInfo),
|
this.openViduWebhookService.sendRecordingEndedWebhook(recordingInfo),
|
||||||
this.recordingService.releaseRoomRecordingActiveLock(roomId)
|
this.recordingService.releaseRecordingLockIfNoEgress(roomId)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,36 +58,49 @@ export class RecordingService {
|
|||||||
async startRecording(roomId: string): Promise<MeetRecordingInfo> {
|
async startRecording(roomId: string): Promise<MeetRecordingInfo> {
|
||||||
let acquiredLock: RedisLock | null = null;
|
let acquiredLock: RedisLock | null = null;
|
||||||
let eventListener!: (info: Record<string, unknown>) => void;
|
let eventListener!: (info: Record<string, unknown>) => void;
|
||||||
|
let recordingId = '';
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.validateRoomsPreconditions(roomId);
|
const room = await this.roomService.getMeetRoom(roomId);
|
||||||
|
|
||||||
|
if (!room) throw errorRoomNotFound(roomId);
|
||||||
|
|
||||||
|
//TODO: Check if the room has participants before starting the recording
|
||||||
|
//room.numParticipants === 0 ? throw errorNoParticipants(roomId);
|
||||||
|
const lkRoom = await this.livekitService.getRoom(roomId);
|
||||||
|
|
||||||
|
if (!lkRoom) throw errorRoomNotFound(roomId);
|
||||||
|
|
||||||
|
const hasParticipants = await this.livekitService.roomHasParticipants(roomId);
|
||||||
|
|
||||||
|
if (!hasParticipants) throw errorRoomHasNoParticipants(roomId);
|
||||||
|
|
||||||
// Attempt to acquire lock. If the lock is not acquired, the recording is already active.
|
// Attempt to acquire lock. If the lock is not acquired, the recording is already active.
|
||||||
acquiredLock = await this.acquireRoomRecordingActiveLock(roomId);
|
acquiredLock = await this.acquireRoomRecordingActiveLock(roomId);
|
||||||
|
|
||||||
if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId);
|
if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId);
|
||||||
|
|
||||||
let resolveRecording!: (r: MeetRecordingInfo) => void;
|
const startTimeoutPromise = new Promise<never>((_, reject) => {
|
||||||
let rejectRecording!: (e: unknown) => void;
|
timeoutId = setTimeout(() => {
|
||||||
const recordingPromise = new Promise<MeetRecordingInfo>((res, rej) => {
|
|
||||||
resolveRecording = res;
|
|
||||||
rejectRecording = rej;
|
|
||||||
});
|
|
||||||
let recordingId = '';
|
|
||||||
|
|
||||||
eventListener = (info: Record<string, unknown>) => {
|
|
||||||
// This listener is triggered only for the instance that started the recording.
|
|
||||||
// Check if the recording ID matches the one that was started
|
|
||||||
const isEventForCurrentRecording = info?.recordingId === recordingId && info?.roomId === roomId;
|
|
||||||
|
|
||||||
if (isEventForCurrentRecording) {
|
|
||||||
this.taskSchedulerService.cancelTask(`${roomId}_recording_timeout`);
|
|
||||||
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
||||||
resolveRecording(info as unknown as MeetRecordingInfo);
|
this.handleRecordingLockTimeout(recordingId, roomId, reject);
|
||||||
}
|
}, ms(INTERNAL_CONFIG.RECORDING_STARTED_TIMEOUT));
|
||||||
};
|
});
|
||||||
|
|
||||||
this.systemEventService.on(SystemEventType.RECORDING_ACTIVE, eventListener);
|
const eventReceivedPromise = new Promise<MeetRecordingInfo>((resolve) => {
|
||||||
this.registerRecordingTimeout(roomId, recordingId, eventListener, rejectRecording);
|
eventListener = (info: Record<string, unknown>) => {
|
||||||
|
// Process the event only if it belongs to the current room.
|
||||||
|
// Each room has only ONE active recording at the same time
|
||||||
|
if (info?.roomId !== roomId) return;
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
||||||
|
resolve(info as unknown as MeetRecordingInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.systemEventService.on(SystemEventType.RECORDING_ACTIVE, eventListener);
|
||||||
|
});
|
||||||
|
|
||||||
const options = this.generateCompositeOptionsFromRequest();
|
const options = this.generateCompositeOptionsFromRequest();
|
||||||
const output = this.generateFileOutputFromRequest(roomId);
|
const output = this.generateFileOutputFromRequest(roomId);
|
||||||
@ -95,17 +108,26 @@ export class RecordingService {
|
|||||||
const recordingInfo = RecordingHelper.toRecordingInfo(egressInfo);
|
const recordingInfo = RecordingHelper.toRecordingInfo(egressInfo);
|
||||||
recordingId = recordingInfo.recordingId;
|
recordingId = recordingInfo.recordingId;
|
||||||
|
|
||||||
return await recordingPromise;
|
if (recordingInfo.status === MeetRecordingStatus.ACTIVE) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
||||||
|
return recordingInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.race([eventReceivedPromise, startTimeoutPromise]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error starting recording in room '${roomId}': ${error}`);
|
this.logger.error(`Error starting recording in room '${roomId}': ${error}`);
|
||||||
|
|
||||||
if (acquiredLock) await this.releaseRoomRecordingActiveLock(roomId);
|
|
||||||
|
|
||||||
if (eventListener) this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
|
||||||
|
|
||||||
this.taskSchedulerService.cancelTask(`${roomId}_recording_timeout`);
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, eventListener);
|
||||||
|
|
||||||
|
if (acquiredLock) await this.releaseRecordingLockIfNoEgress(roomId);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Failed to release recording lock: ${e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,12 +349,13 @@ export class RecordingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases the active recording lock for a specific room.
|
* Releases the active recording lock for a specified room, but only if there are no active egress operations.
|
||||||
*
|
*
|
||||||
* This method attempts to release a lock associated with the active recording
|
* This method first checks for any ongoing egress operations for the room.
|
||||||
* of a given room.
|
* If active egress operations are found, the lock isn't released as recording is still considered active.
|
||||||
|
* Otherwise, it proceeds to release the mutex lock associated with the room's recording.
|
||||||
*/
|
*/
|
||||||
async releaseRoomRecordingActiveLock(roomId: string): Promise<void> {
|
async releaseRecordingLockIfNoEgress(roomId: string): Promise<void> {
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
const lockName = MeetLock.getRecordingActiveLock(roomId);
|
const lockName = MeetLock.getRecordingActiveLock(roomId);
|
||||||
const egress = await this.livekitService.getActiveEgress(roomId);
|
const egress = await this.livekitService.getActiveEgress(roomId);
|
||||||
@ -341,7 +364,7 @@ export class RecordingService {
|
|||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
`Active egress found for room ${roomId}: ${egress.map((e) => e.egressId).join(', ')}`
|
`Active egress found for room ${roomId}: ${egress.map((e) => e.egressId).join(', ')}`
|
||||||
);
|
);
|
||||||
this.logger.error(`Cannot release recording lock for room '${roomId}'.`);
|
this.logger.debug(`Cannot release recording lock for room '${roomId}'. Recording is still active.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,22 +389,6 @@ export class RecordingService {
|
|||||||
return this.roomService.sendSignal(roomId, payload, options);
|
return this.roomService.sendSignal(roomId, payload, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async validateRoomsPreconditions(roomId: string): Promise<void> {
|
|
||||||
const room = await this.roomService.getMeetRoom(roomId);
|
|
||||||
|
|
||||||
if (!room) throw errorRoomNotFound(roomId);
|
|
||||||
|
|
||||||
//TODO: Check if the room has participants before starting the recording
|
|
||||||
//room.numParticipants === 0 ? throw errorNoParticipants(roomId);
|
|
||||||
const lkRoom = await this.livekitService.getRoom(roomId);
|
|
||||||
|
|
||||||
if (!lkRoom) throw errorRoomNotFound(roomId);
|
|
||||||
|
|
||||||
const hasParticipants = await this.livekitService.roomHasParticipants(roomId);
|
|
||||||
|
|
||||||
if (!hasParticipants) throw errorRoomHasNoParticipants(roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the data required to delete a recording, including the file paths
|
* Retrieves the data required to delete a recording, including the file paths
|
||||||
* to be deleted and the recording's metadata information.
|
* to be deleted and the recording's metadata information.
|
||||||
@ -498,10 +505,8 @@ export class RecordingService {
|
|||||||
protected async handleRecordingLockTimeout(
|
protected async handleRecordingLockTimeout(
|
||||||
recordingId: string,
|
recordingId: string,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
listener: (info: Record<string, unknown>) => void,
|
|
||||||
rejectRequest: (reason?: unknown) => void
|
rejectRequest: (reason?: unknown) => void
|
||||||
) {
|
) {
|
||||||
this.systemEventService.off(SystemEventType.RECORDING_ACTIVE, listener);
|
|
||||||
this.logger.debug(`Recording cleanup timer triggered for room '${roomId}'.`);
|
this.logger.debug(`Recording cleanup timer triggered for room '${roomId}'.`);
|
||||||
|
|
||||||
let shouldReleaseLock = false;
|
let shouldReleaseLock = false;
|
||||||
@ -537,7 +542,7 @@ export class RecordingService {
|
|||||||
} finally {
|
} finally {
|
||||||
if (shouldReleaseLock) {
|
if (shouldReleaseLock) {
|
||||||
try {
|
try {
|
||||||
await this.releaseRoomRecordingActiveLock(roomId);
|
await this.releaseRecordingLockIfNoEgress(roomId);
|
||||||
this.logger.debug(`Recording active lock released for room ${roomId}.`);
|
this.logger.debug(`Recording active lock released for room ${roomId}.`);
|
||||||
} catch (releaseError) {
|
} catch (releaseError) {
|
||||||
this.logger.error(`Error releasing active recording lock for room ${roomId}: ${releaseError}`);
|
this.logger.error(`Error releasing active recording lock for room ${roomId}: ${releaseError}`);
|
||||||
@ -621,20 +626,6 @@ export class RecordingService {
|
|||||||
await this.s3Service.saveObject(metadataPath, recordingInfo);
|
await this.s3Service.saveObject(metadataPath, recordingInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected registerRecordingTimeout(
|
|
||||||
roomId: string,
|
|
||||||
recordingId: string,
|
|
||||||
eventListener: (info: Record<string, unknown>) => void,
|
|
||||||
reject: (reason?: unknown) => void
|
|
||||||
): void {
|
|
||||||
this.taskSchedulerService.registerTask({
|
|
||||||
name: `${roomId}_recording_timeout`,
|
|
||||||
type: 'timeout',
|
|
||||||
scheduleOrDelay: INTERNAL_CONFIG.RECORDING_STARTED_TIMEOUT,
|
|
||||||
callback: this.handleRecordingLockTimeout.bind(this, recordingId, roomId, eventListener, reject)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up orphaned recording locks in the system.
|
* Cleans up orphaned recording locks in the system.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -127,6 +127,7 @@ export class TaskSchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.scheduledTasks.delete(name);
|
this.scheduledTasks.delete(name);
|
||||||
|
this.taskRegistry = this.taskRegistry.filter((task) => task.name !== name);
|
||||||
this.logger.debug(`Task "${name}" cancelled.`);
|
this.logger.debug(`Task "${name}" cancelled.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export const runReleaseActiveRecordingLock = async (roomId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recordingService = container.get(RecordingService);
|
const recordingService = container.get(RecordingService);
|
||||||
await recordingService.releaseRoomRecordingActiveLock(roomId);
|
await recordingService.releaseRecordingLockIfNoEgress(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user