/**
* Error Recovery Infrastructure
*
* Migrated from src/utils/errorRecovery.ts to infrastructure layer.
* Provides comprehensive error handling, logging, and recovery mechanisms.
*/
import { writeFileSync, existsSync, readFileSync, mkdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { ErrorContext, ProgressData } from '../../types/index';
import type { ILoggingService } from '../../di/interfaces';
/**
* Record of an error that occurred during processing
*/
export interface ErrorRecord {
/** ISO timestamp when the error occurred */
timestamp: string;
/** Operation being performed when error occurred */
operation: string;
/** File path if applicable */
filePath?: string;
/** Error message */
error: string;
/** Type/category of error */
errorType: string;
/** Stack trace if available */
stackTrace?: string;
/** Number of retry attempts made */
retryCount: number;
/** Whether the error was successfully recovered from */
recovered: boolean;
}
/**
* Error that occurred during file processing
*/
export interface ProcessingError {
/** Path to the file being processed */
filePath: string;
/** Operation being performed */
operation: string;
/** The actual error object */
error: Error;
/** Additional context about the error */
context?: ErrorContext;
}
/**
* Configuration for retry logic
*/
export interface RetryOptions {
/** Maximum number of retry attempts */
maxRetries: number;
/** Initial backoff delay in milliseconds */
backoffMs: number;
/** Multiplier for exponential backoff */
backoffMultiplier: number;
}
export class ErrorRecoveryManager {
private errorLogPath: string;
private errors: ErrorRecord[] = [];
private retryOptions: RetryOptions;
private loggingService: ILoggingService | undefined;
constructor(cacheDir: string, retryOptions?: Partial<RetryOptions>, loggingService?: ILoggingService | undefined) {
this.errorLogPath = join(cacheDir, 'errors.log');
this.retryOptions = {
maxRetries: 3,
backoffMs: 1000,
backoffMultiplier: 2,
...retryOptions
};
this.loggingService = loggingService;
// Load existing errors if log exists
this.loadExistingErrors();
}
/**
* Detect if we're in a test environment
*/
private isTestEnvironment(): boolean {
return process.env.NODE_ENV === 'test' ||
process.env.VITEST === 'true' ||
process.argv.some(arg => arg.includes('vitest') || arg.includes('test'));
}
/**
* Load existing errors from the log file
*/
private loadExistingErrors(): void {
if (!existsSync(this.errorLogPath)) {
return;
}
try {
const logContent = readFileSync(this.errorLogPath, 'utf8');
const lines = logContent.trim().split('\n');
for (const line of lines) {
if (line.trim()) {
try {
const errorRecord = JSON.parse(line) as ErrorRecord;
this.errors.push(errorRecord);
} catch (parseError) {
// Skip malformed log entries
continue;
}
}
}
} catch (error) {
// If we can't load the log, start fresh
console.warn(`Warning: Could not load existing error log: ${error}`);
}
}
/**
* Record an error that occurred during processing
*/
recordError(error: ProcessingError, retryCount: number = 0, recovered: boolean = false): void {
const errorRecord: ErrorRecord = {
timestamp: new Date().toISOString(),
operation: error.operation,
filePath: error.filePath,
error: error.error.message,
errorType: error.error.name || 'UnknownError',
...(error.error.stack && { stackTrace: error.error.stack }),
retryCount,
recovered
};
this.errors.push(errorRecord);
this.logErrorToFile(errorRecord);
// Log to service if available
if (this.loggingService) {
this.loggingService.error(
`Error in ${error.operation}${error.filePath ? ` for file ${error.filePath}` : ''}`,
error.error,
{
operation: error.operation,
filePath: error.filePath,
retryCount,
recovered,
errorType: error.error.name
}
);
}
}
/**
* Log error record to file
*/
private logErrorToFile(errorRecord: ErrorRecord): void {
try {
// Ensure directory exists
const logDir = this.errorLogPath.substring(0, this.errorLogPath.lastIndexOf('/'));
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
// Append to log file as JSON lines
const logLine = JSON.stringify(errorRecord) + '\n';
writeFileSync(this.errorLogPath, logLine, { flag: 'a' });
} catch (writeError) {
console.error(`Failed to write to error log: ${writeError}`);
}
}
/**
* Execute an operation with retry logic and error handling
*/
async executeWithRetry<T>(
operation: () => Promise<T>,
context: { operation: string; filePath?: string }
): Promise<T> {
let lastError: Error;
let retryCount = 0;
while (retryCount <= this.retryOptions.maxRetries) {
try {
const result = await operation();
// If we had previous failures but succeeded now, record recovery
if (retryCount > 0) {
this.recordError(
{
filePath: context.filePath || '',
operation: context.operation,
error: lastError!,
context: context as ErrorContext
},
retryCount,
true // recovered
);
}
return result;
} catch (error) {
lastError = error as Error;
if (retryCount === this.retryOptions.maxRetries) {
// Final failure - record the error
this.recordError(
{
filePath: context.filePath || '',
operation: context.operation,
error: lastError,
context: context as ErrorContext
},
retryCount,
false // not recovered
);
throw lastError;
}
// Calculate backoff delay (optimized for tests)
const baseDelay = this.isTestEnvironment() ?
Math.max(1, Math.floor(this.retryOptions.backoffMs / 35)) : // 35x faster for tests
this.retryOptions.backoffMs;
const delay = baseDelay * Math.pow(this.retryOptions.backoffMultiplier, retryCount);
retryCount++;
// Log retry attempt
if (this.loggingService) {
this.loggingService.warn(
`Retrying ${context.operation} (attempt ${retryCount}/${this.retryOptions.maxRetries}) after ${delay}ms`,
{
operation: context.operation,
filePath: context.filePath,
retryCount,
delay,
error: lastError.message
}
);
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError!;
}
/**
* Get summary of all errors
*/
getErrorSummary(): {
totalErrors: number;
errorsByType: Record<string, number>;
errorsByOperation: Record<string, number>;
recoveredErrors: number;
recentErrors: ErrorRecord[];
} {
const errorsByType: Record<string, number> = {};
const errorsByOperation: Record<string, number> = {};
let recoveredErrors = 0;
for (const error of this.errors) {
// Count by type
errorsByType[error.errorType] = (errorsByType[error.errorType] || 0) + 1;
// Count by operation
errorsByOperation[error.operation] = (errorsByOperation[error.operation] || 0) + 1;
// Count recovered
if (error.recovered) {
recoveredErrors++;
}
}
// Get recent errors (last 10)
const recentErrors = this.errors.slice(-10);
return {
totalErrors: this.errors.length,
errorsByType,
errorsByOperation,
recoveredErrors,
recentErrors
};
}
/**
* Clear all recorded errors
*/
clearErrors(): void {
this.errors = [];
// Remove log file
if (existsSync(this.errorLogPath)) {
try {
unlinkSync(this.errorLogPath);
} catch (error) {
console.warn(`Warning: Could not clear error log: ${error}`);
}
}
}
/**
* Display error summary to console
*/
displayErrorSummary(): void {
const summary = this.getErrorSummary();
if (summary.totalErrors === 0) {
console.log('ā No errors occurred during processing');
return;
}
console.log(`\nš Error Summary:`);
console.log(` Total errors: ${summary.totalErrors}`);
console.log(` Recovered: ${summary.recoveredErrors}`);
console.log(` Failed: ${summary.totalErrors - summary.recoveredErrors}`);
if (Object.keys(summary.errorsByType).length > 0) {
console.log(`\n By type:`);
for (const [type, count] of Object.entries(summary.errorsByType)) {
console.log(` ${type}: ${count}`);
}
}
if (Object.keys(summary.errorsByOperation).length > 0) {
console.log(`\n By operation:`);
for (const [operation, count] of Object.entries(summary.errorsByOperation)) {
console.log(` ${operation}: ${count}`);
}
}
if (summary.recentErrors.length > 0) {
console.log(`\n Recent errors:`);
for (const error of summary.recentErrors.slice(-3)) {
console.log(` ${error.timestamp}: ${error.operation} - ${error.error}`);
}
}
}
}
/**
* Atomic file operations to prevent corruption
*/
export class AtomicFileOperations {
/**
* Write JSON data atomically to prevent corruption
*/
static async writeJSONAtomic(filePath: string, data: any): Promise<void> {
const tempPath = `${filePath}.tmp`;
try {
// Write to temporary file first
writeFileSync(tempPath, JSON.stringify(data, null, 2));
// Atomic move to final location
if (existsSync(filePath)) {
unlinkSync(filePath);
}
// Rename temp file to final name (atomic on most filesystems)
const fs = await import('fs/promises');
await fs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
if (existsSync(tempPath)) {
try {
unlinkSync(tempPath);
} catch (cleanupError) {
// Ignore cleanup errors
}
}
throw error;
}
}
/**
* Write text content atomically to prevent corruption
*/
static async writeFileAtomic(filePath: string, content: string): Promise<void> {
const tempPath = `${filePath}.tmp`;
try {
// Write to temporary file first
writeFileSync(tempPath, content);
// Atomic move to final location
if (existsSync(filePath)) {
unlinkSync(filePath);
}
// Rename temp file to final name (atomic on most filesystems)
const fs = await import('fs/promises');
await fs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
if (existsSync(tempPath)) {
try {
unlinkSync(tempPath);
} catch (cleanupError) {
// Ignore cleanup errors
}
}
throw error;
}
}
}
/**
* Resumable progress tracking for interrupted operations
*/
export class ResumableProgress {
private progressFile: string;
private data: ProgressData;
constructor(cacheDir: string, operationName: string) {
this.progressFile = join(cacheDir, `progress-${operationName}.json`);
this.data = this.loadProgress();
}
loadProgress(): ProgressData {
if (!existsSync(this.progressFile)) {
return {
operation: '',
current: 0,
total: 0,
totalFiles: 0,
processedFiles: 0,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
lastUpdate: new Date().toISOString(),
processedPaths: [],
failedPaths: []
};
}
try {
const content = readFileSync(this.progressFile, 'utf8');
return JSON.parse(content);
} catch (error) {
console.warn(`Warning: Could not load progress file: ${error}`);
return {
operation: '',
current: 0,
total: 0,
totalFiles: 0,
processedFiles: 0,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
lastUpdate: new Date().toISOString(),
processedPaths: [],
failedPaths: []
};
}
}
updateProgress(processedFile: string, failed: boolean = false): void {
this.data.processedFiles++;
this.data.current = this.data.processedFiles;
this.data.lastUpdate = new Date().toISOString();
this.data.lastUpdated = new Date().toISOString();
if (failed) {
this.data.failedPaths.push(processedFile);
} else {
this.data.processedPaths.push(processedFile);
}
this.saveProgress();
}
initializeOperation(operation: string, totalFiles: number): void {
this.data = {
operation,
current: 0,
total: totalFiles,
totalFiles,
processedFiles: 0,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
lastUpdate: new Date().toISOString(),
processedPaths: [],
failedPaths: []
};
this.saveProgress();
}
saveProgress(current?: number, total?: number, metadata?: Record<string, unknown>): void {
if (current !== undefined) {
this.data.current = current;
this.data.processedFiles = current;
}
if (total !== undefined) {
this.data.total = total;
this.data.totalFiles = total;
}
if (metadata !== undefined) {
this.data.metadata = metadata;
}
this.data.lastUpdated = new Date().toISOString();
this.data.lastUpdate = new Date().toISOString();
try {
AtomicFileOperations.writeJSONAtomic(this.progressFile, this.data);
} catch (error) {
console.warn(`Warning: Could not save progress: ${error}`);
}
}
getProgress(): ProgressData {
return { ...this.data };
}
isFileProcessed(filePath: string): boolean {
return this.data.processedPaths.includes(filePath);
}
clearProgress(): void {
if (existsSync(this.progressFile)) {
try {
unlinkSync(this.progressFile);
} catch (error) {
console.warn(`Warning: Could not clear progress file: ${error}`);
}
}
}
}