Files
ReadMeABook/src/lib/processors/retry-failed-imports.processor.ts
T
kikootwo 3820b9b21d Add DB pooling, throttling and monitor backoff
Add connection pool params to DATABASE_URL and configure Prisma to use the pooled URL (connection_limit=20, pool_timeout=30) to reduce connection exhaustion. Introduce safeguards and throttling across processors: limit in-flight progress DB updates in direct-download, add short delays when processing RSS, retry-failed-imports, and retry-missing-torrents, and stagger scheduler triggers to avoid bursts. Implement adaptive monitor-download polling with stallCount/lastProgress and exponential backoff, and thread these fields through JobQueueService (including reduced worker concurrency for several queues). Batch audiobook enrichment queries to small parallel batches to limit DB load. Update tests to reflect new monitor payload parameters. Overall intent: reduce DB connection pool pressure and smooth load spikes during startup and heavy processing.
2026-02-18 02:43:00 -05:00

242 lines
9.5 KiB
TypeScript

/**
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import.
* Uses the IDownloadClient interface for client-agnostic path resolution.
*/
import path from 'path';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
export interface RetryFailedImportsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processRetryFailedImports(payload: RetryFailedImportsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = RMABLogger.forJob(jobId, 'RetryFailedImports');
logger.info('Starting retry job for requests awaiting import...');
try {
// Initialize config and download client manager
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
// Helper function to get path mapping config for a specific download client type
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig && clientConfig.remotePathMappingEnabled) {
return {
enabled: true,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
}
return { enabled: false, remotePath: '', localPath: '' };
};
// Find all requests in awaiting_import status (both audiobook and ebook)
// The organize_files processor handles both types with type-based branching
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_import',
deletedAt: null,
},
include: {
audiobook: true,
downloadHistory: {
where: { selected: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
take: 50, // Limit to 50 requests per run
});
logger.info(`Found ${requests.length} requests awaiting import`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting import',
triggered: 0,
};
}
// Trigger organize job for each request
const jobQueue = getJobQueueService();
let triggered = 0;
let skipped = 0;
for (const request of requests) {
try {
// Get the download path from the most recent download history
const downloadHistory = request.downloadHistory[0];
if (!downloadHistory) {
logger.warn(`No download history found for request ${request.id}, skipping`);
skipped++;
continue;
}
let downloadPath: string;
// Get path mapping for this specific download client
const clientType = downloadHistory.downloadClient || 'qbittorrent';
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
// Use stored path or construct from download_dir directly
if (clientType === 'direct') {
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
} else {
// Real download client — resolve path via client API with path mapping
const mappingConfig = await getPathMappingForClient(clientType);
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
skipped++;
continue;
}
if (clientId) {
// Try to get path from download client via unified interface
const client = await manager.getClientServiceForProtocol(protocol);
if (client) {
try {
const info = await client.getDownload(clientId);
if (info?.downloadPath) {
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
logger.info(
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
(downloadPath !== info.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
// Download found but no path — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} catch (clientError) {
// Client error — try stored path, then fallback
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client configured — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client ID — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
}
// Check if we got a valid path (getFallbackPath returns empty string on failure)
if (!downloadPath) {
skipped++;
continue;
}
await jobQueue.addOrganizeJob(
request.id,
request.audiobook.id,
downloadPath
);
triggered++;
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
logger.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
return {
success: true,
message: 'Retry failed imports completed',
totalRequests: requests.length,
triggered,
skipped,
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Return the stored download path from the database (saved at download completion time).
* Returns empty string if not available (old records won't have this field).
*/
function getStoredPath(
downloadHistory: { downloadPath?: string | null },
requestId: string,
logger: RMABLogger
): string {
if (downloadHistory.downloadPath) {
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
return downloadHistory.downloadPath;
}
return '';
}
/**
* Construct a fallback download path from config when the download client can't provide one.
* Returns empty string if path cannot be determined (caller should skip the request).
*/
async function getFallbackPath(
downloadHistory: { torrentName: string | null },
configService: any,
mappingConfig: PathMappingConfig,
requestId: string,
logger: RMABLogger,
manager?: DownloadClientManager,
protocol?: ProtocolType
): Promise<string> {
if (!downloadHistory.torrentName) {
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
return '';
}
const baseDir = await configService.get('download_dir');
if (!baseDir) {
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
return '';
}
// Resolve customPath from the client config if available
let downloadDir = baseDir;
if (manager && protocol) {
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig?.customPath) {
downloadDir = path.join(baseDir, clientConfig.customPath);
}
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
(mappedPath !== fallbackPath ? `${mappedPath} (mapped)` : '')
);
return mappedPath;
}