UNPKG

@eleven-am/transcoder

Version:

High-performance HLS transcoding library with hardware acceleration, intelligent client management, and distributed processing support for Node.js

405 lines 19.1 kB
"use strict"; /* * @eleven-am/transcoder * Copyright (C) 2025 Roy OSSAI * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.DistributedSegmentProcessor = void 0; const fs = __importStar(require("fs")); const os = __importStar(require("os")); const fp_1 = require("@eleven-am/fp"); const localSegmentProcessor_1 = require("./localSegmentProcessor"); const redisSegmentClaimManager_1 = require("./redisSegmentClaimManager"); /** * Distributed segment processor - coordinate segment processing across multiple nodes * Falls back to local processing if Redis is unavailable */ class DistributedSegmentProcessor { constructor(redis, config = {}) { this.redis = redis; this.disposed = false; this.MAX_ACTIVE_RENEWALS = 1000; this.activeRenewals = new Map(); // Pure helper functions for segment processing this.createCachedResult = (data) => ({ success: true, segmentIndex: data.segmentIndex, outputPath: data.outputPath, cached: true, }); this.createFileNotFoundResult = (data) => ({ success: false, segmentIndex: data.segmentIndex, outputPath: data.outputPath, cached: true, error: new Error('Segment marked complete but file not found'), }); this.checkSegmentExistsOnDisk = (outputPath) => fp_1.TaskEither.tryCatch(() => this.segmentExists(outputPath), 'Failed to check segment existence'); this.checkRedisCompletionStatus = (data) => fp_1.TaskEither.tryCatch(() => this.claimManager.isSegmentCompleted(data.fileId, data.streamType, data.quality, data.streamIndex, data.segmentIndex), 'Failed to check completion status'); this.waitForFileTask = (outputPath, timeout) => fp_1.TaskEither.tryCatch(() => this.waitForFile(outputPath, timeout), 'Failed to wait for file'); this.claimSegmentTask = (data) => fp_1.TaskEither.tryCatch(() => this.claimManager.claimSegment(data.fileId, data.streamType, data.quality, data.streamIndex, data.segmentIndex, data.outputPath), 'Failed to claim segment'); this.setupClaimRenewal = (claim, segmentIndex) => fp_1.TaskEither.of(null).map(() => { const timer = setInterval(async () => { try { const extended = await claim.extend(); if (!extended) { console.warn(`Failed to extend claim for segment ${segmentIndex}`); } } catch (error) { console.error(`Error extending claim for segment ${segmentIndex}:`, error); } }, this.claimRenewalInterval); // Implement LRU eviction if at capacity if (this.activeRenewals.size >= this.MAX_ACTIVE_RENEWALS) { this.evictOldestRenewal(); } this.activeRenewals.set(claim.segmentKey, timer); return timer; }); this.processSegmentLocally = (data) => fp_1.TaskEither.tryCatch(() => this.localProcessor.processSegment(data), 'Failed to process segment locally'); this.markSegmentComplete = (data) => fp_1.TaskEither.tryCatch(() => this.claimManager.markSegmentCompleted(data.fileId, data.streamType, data.quality, data.streamIndex, data.segmentIndex), 'Failed to mark segment complete'); this.publishSegmentComplete = (data) => fp_1.TaskEither.tryCatch(() => this.claimManager.publishSegmentComplete(data.fileId, data.streamType, data.quality, data.streamIndex, data.segmentIndex), 'Failed to publish completion'); this.handleSuccessfulProcessing = (data, result) => result.success ? this.markSegmentComplete(data) .chain(() => this.publishSegmentComplete(data)) .map(() => result) : fp_1.TaskEither.of(result); this.waitForSegmentCompletionTask = (data) => fp_1.TaskEither.tryCatch(() => this.waitForSegmentCompletion(data), 'Failed to wait for segment completion'); // Pure helper function to clean up claim and renewal timer this.cleanupClaimAndTimer = (claim, renewalTimer) => { const cleanupTimer = () => { if (renewalTimer) { clearInterval(renewalTimer); if (claim?.segmentKey) { this.cleanupRenewal(claim.segmentKey); } } }; const releaseClaim = async () => { if (claim?.acquired) { try { await claim.release(); } catch (err) { console.error('CRITICAL: Failed to release claim during cleanup:', { segmentKey: claim.segmentKey, workerId: this.workerId, error: err, }); // Do not re-throw - the claim will expire via TTL } } }; return fp_1.TaskEither.of(undefined) .ioSync(cleanupTimer) .io(() => fp_1.TaskEither.tryCatch(() => releaseClaim(), 'Failed to release claim').orElse(() => fp_1.TaskEither.of(undefined))); }; // Pure helper function to handle Redis errors with fallback this.handleRedisErrorWithFallback = (error, data) => { const errorObj = error.error || error; if (this.fallbackToLocal && this.isRedisError(errorObj)) { console.warn('Redis error, falling back to local processing:', errorObj); return fp_1.TaskEither.tryCatch(() => this.localProcessor.processSegment(data), 'Local processing failed'); } return fp_1.TaskEither.of({ success: false, segmentIndex: data.segmentIndex, outputPath: data.outputPath, error: new Error(errorObj.message || 'Unknown error'), }); }; this.checkRedisConnection = () => fp_1.TaskEither.tryCatch(() => this.redis.ping().then(() => true), 'Redis connection failed'); this.determineHealthStatus = (disposed, redisAvailable) => { if (disposed) return false; return redisAvailable || this.fallbackToLocal; }; // Helper functions for waitForSegmentCompletion this.createCompletionState = () => ({ startTime: Date.now(), unsubscribe: null, checkInterval: null, segmentCompleted: false, }); this.cleanupSubscriptions = (state) => { const clearTimer = () => { if (state.checkInterval) { clearInterval(state.checkInterval); } }; const unsubscribe = async () => { if (state.unsubscribe) { try { await state.unsubscribe(); } catch (err) { console.error('Error during unsubscribe in cleanup:', err); } } }; return fp_1.TaskEither.of(undefined) .ioSync(clearTimer) .io(() => fp_1.TaskEither.tryCatch(() => unsubscribe(), 'Failed to unsubscribe').orElse(() => fp_1.TaskEither.of(undefined))); }; this.subscribeToCompletion = (data, state) => fp_1.TaskEither.tryCatch(() => this.claimManager.subscribeToSegmentComplete(data.fileId, data.streamType, data.quality, data.streamIndex, data.segmentIndex, () => { state.segmentCompleted = true; }), 'Failed to subscribe to completion'); this.createPeriodicFileCheck = (data, state) => new Promise((resolve) => { const checkFile = async () => { if (state.segmentCompleted || await this.segmentExists(data.outputPath)) { if (state.checkInterval) { clearInterval(state.checkInterval); } resolve(true); } }; // Check immediately checkFile(); // Then check periodically state.checkInterval = setInterval(checkFile, 1000); }); this.createTimeoutPromise = (timeout) => new Promise((resolve) => { setTimeout(() => resolve(false), timeout); }); this.createSuccessResult = (data, startTime) => ({ success: true, segmentIndex: data.segmentIndex, outputPath: data.outputPath, cached: true, processingTime: Date.now() - startTime, }); this.createTimeoutResult = (data, startTime) => ({ success: false, segmentIndex: data.segmentIndex, outputPath: data.outputPath, error: new Error(`Timeout waiting for segment ${data.segmentIndex} after ${this.segmentTimeout}ms`), processingTime: Date.now() - startTime, }); this.createErrorResult = (data, error, startTime) => ({ success: false, segmentIndex: data.segmentIndex, outputPath: data.outputPath, error, processingTime: Date.now() - startTime, }); this.delay = (ms) => fp_1.TaskEither.tryCatch(() => new Promise((resolve) => setTimeout(resolve, ms)), 'Delay failed'); this.checkFileWithRetry = (filePath, startTime, timeout) => fp_1.TaskEither.of(Date.now() - startTime) .chain(elapsed => elapsed < timeout ? fp_1.TaskEither.of(elapsed) : fp_1.TaskEither.error((0, fp_1.createInternalError)('Timeout waiting for file'))) .chain(() => this.checkSegmentExistsOnDisk(filePath)) .matchTask([ { predicate: exists => exists, run: () => fp_1.TaskEither.of(true), }, { predicate: () => true, run: () => this.delay(100) .chain(() => this.checkFileWithRetry(filePath, startTime, timeout)), }, ]) .orElse(() => fp_1.TaskEither.of(false)); this.checkFileAccess = (filePath) => fp_1.TaskEither.tryCatch(() => fs.promises.access(filePath).then(() => true), 'File access failed') .orElse(() => fp_1.TaskEither.of(false)); // Memory management helper methods this.evictOldestRenewal = () => { const firstKey = this.activeRenewals.keys().next().value; if (firstKey) { this.cleanupRenewal(firstKey); } }; this.cleanupRenewal = (segmentKey) => { const timer = this.activeRenewals.get(segmentKey); if (timer) { clearInterval(timer); this.activeRenewals.delete(segmentKey); } }; this.clearAllRenewals = () => { for (const timer of this.activeRenewals.values()) { clearInterval(timer); } this.activeRenewals.clear(); }; this.workerId = config.workerId || process.env.HOSTNAME || os.hostname(); this.claimRenewalInterval = config.claimRenewalInterval || 20000; this.segmentTimeout = config.segmentTimeout || 30000; this.fallbackToLocal = config.fallbackToLocal !== false; this.fileWaitTimeout = config.fileWaitTimeout || 10000; // Default 10 seconds this.claimManager = new redisSegmentClaimManager_1.RedisSegmentClaimManager(redis, this.workerId, config.claimTTL || 60000, config.completedSegmentTTL); this.localProcessor = new localSegmentProcessor_1.LocalSegmentProcessor(this.workerId); } async processSegment(data) { let renewalTimer = null; let claim = null; return this.checkSegmentExistsOnDisk(data.outputPath) .matchTask([ { predicate: (exists) => exists, run: () => fp_1.TaskEither.of(this.createCachedResult(data)), }, { predicate: () => true, run: () => this.checkRedisCompletionStatus(data) .matchTask([ { predicate: (isCompleted) => isCompleted, run: () => this.waitForFileTask(data.outputPath, this.fileWaitTimeout) .map(fileAppeared => fileAppeared ? this.createCachedResult(data) : this.createFileNotFoundResult(data)), }, { predicate: () => true, run: () => this.claimSegmentTask(data) .ioSync(c => { claim = c; }) .matchTask([ { predicate: (c) => !c.acquired, run: () => this.waitForSegmentCompletionTask(data), }, { predicate: (c) => c.acquired, run: (c) => this.setupClaimRenewal(c, data.segmentIndex) .ioSync(timer => { renewalTimer = timer; }) .chain(() => this.processSegmentLocally(data)) .chain(result => this.handleSuccessfulProcessing(data, result)), }, ]), }, ]), }, ]) .orElse((error) => this.handleRedisErrorWithFallback(error, data)) .io(() => this.cleanupClaimAndTimer(claim, renewalTimer)) .toPromise(); } async isHealthy() { return this.checkRedisConnection() .map(redisAvailable => this.determineHealthStatus(this.disposed, redisAvailable)) .orElse(() => fp_1.TaskEither.of(this.determineHealthStatus(this.disposed, false))) .toPromise(); } getMode() { return 'distributed'; } async dispose() { this.disposed = true; // Clear all renewal timers this.clearAllRenewals(); // Dispose local processor await this.localProcessor.dispose(); } /** * Get all currently claimed segments * Public method for work stealing processor */ async getClaimedSegments() { return this.claimManager.getClaimedSegments(); } /** * Force claim a segment * Public method for work stealing processor */ async forceClaimSegment(segmentKey, workerId, outputPath) { return this.claimManager.forceClaimSegment(segmentKey, workerId, outputPath); } async waitForSegmentCompletion(data) { const state = this.createCompletionState(); try { // Subscribe to completion events state.unsubscribe = await this.subscribeToCompletion(data, state).toPromise(); // Set up periodic file check const checkPromise = this.createPeriodicFileCheck(data, state); // Wait for either completion or timeout const timeoutPromise = this.createTimeoutPromise(this.segmentTimeout); const completed = await Promise.race([checkPromise, timeoutPromise]); await this.cleanupSubscriptions(state).toPromise(); if (completed) { return this.createSuccessResult(data, state.startTime); } return this.createTimeoutResult(data, state.startTime); } catch (error) { await this.cleanupSubscriptions(state).toPromise(); return this.createErrorResult(data, error, state.startTime); } } async waitForFile(filePath, timeout) { const startTime = Date.now(); return this.checkFileWithRetry(filePath, startTime, timeout).toPromise(); } async segmentExists(filePath) { return this.checkFileAccess(filePath) .orElse(() => fp_1.TaskEither.of(false)) .toPromise(); } isRedisError(error) { // Type guard for Node.js system errors const nodeError = error; if (nodeError?.code === 'ECONNREFUSED' || nodeError?.code === 'ETIMEDOUT') { return true; } // Check error message for Redis-related errors if (error instanceof Error) { const message = error.message.toLowerCase(); return message.includes('redis') || message.includes('connection'); } return false; } /** * Get the claim manager instance * Public method for work stealing processor */ getClaimManager() { return this.claimManager; } } exports.DistributedSegmentProcessor = DistributedSegmentProcessor; //# sourceMappingURL=distributedSegmentProcessor.js.map