Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions src/core/infrastructure/adapters/split/PDFSplitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import { WORKER_CONFIG } from '../../config/worker.config.js';
/** A rendered page, held in memory until it is written to the split directory. */
type RenderedPage = Omit<PageInfo, 'imagePath'> & { content: Buffer };

/** PDF errors that a retry cannot fix: the document itself is protected or invalid. */
function isRetryablePdfError(error: Error): boolean {
const message = error.message.toLowerCase();
return !(
message.includes('password') ||
message.includes('encrypted') ||
message.includes('invalid pdf')
);
}

/**
* PDF splitter implementation using pdf-to-png-converter.
*
Expand Down Expand Up @@ -161,6 +171,7 @@ export class PDFSplitter implements ISplitter {
const taskDir = ImagePathUtil.getTaskDir(taskId);

try {
// Retry whatever the error says: its message embeds the output path, which can hold any word.
return await this.withRetry(async () => {
await fs.mkdir(taskDir, { recursive: true });

Expand All @@ -171,7 +182,7 @@ export class PDFSplitter implements ISplitter {
pages.push({ page, pageSource, imagePath });
}
return pages;
}, 'write page images');
}, 'write page images', () => true);
} catch (error) {
throw new Error(`Failed to write split images to ${taskDir}: ${(error as Error).message}`);
}
Expand All @@ -182,11 +193,13 @@ export class PDFSplitter implements ISplitter {
*
* @param operation - Async operation to retry
* @param operationName - Name for logging
* @param isRetryable - Whether a failure is worth another attempt (default: PDF errors)
* @returns Operation result
*/
private async withRetry<T>(
operation: () => Promise<T>,
operationName: string
operationName: string,
isRetryable: (error: Error) => boolean = isRetryablePdfError
): Promise<T> {
const maxRetries = WORKER_CONFIG.splitter.maxRetries;
const baseDelay = WORKER_CONFIG.splitter.retryDelayBase;
Expand All @@ -199,14 +212,7 @@ export class PDFSplitter implements ISplitter {
} catch (error) {
lastError = error as Error;

// Check for non-retryable errors
const errorMessage = lastError.message.toLowerCase();
if (
errorMessage.includes('password') ||
errorMessage.includes('encrypted') ||
errorMessage.includes('invalid pdf')
) {
// Don't retry for these errors
if (!isRetryable(lastError)) {
throw lastError;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,32 @@ describe('PDFSplitter', () => {
expect(pdfToPng).toHaveBeenCalledTimes(1);
});

it.each(['password', 'encrypted', 'invalid pdf'])(
'should retry a failed write when the output path contains "%s"',
async (word) => {
const target = path.join('/data', word, 'page-1.png');
vi.mocked(fs.writeFile).mockRejectedValueOnce(systemError('EBUSY', 'open', target));

const result = await splitter.split(task);

expect(result.totalPages).toBe(1);
expect(fs.writeFile).toHaveBeenCalledTimes(2);
}
);

it('should fail without writing any page when one has no image', async () => {
vi.mocked(pdfToPng).mockResolvedValue([
{ path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') },
{ path: '', name: 'temp-2.png', pageNumber: 2 },
] as any);

const failure = splitter.split(task);

await expect(failure).rejects.toThrow('Failed to split PDF document.pdf');
await expect(failure).rejects.toThrow('PDF conversion produced no image for page 2');
expect(fs.writeFile).not.toHaveBeenCalled();
});

it('should not blame the source when writing a page keeps failing, even if it is ENOENT', async () => {
const target = path.join(splitDir, 'page-1.png');
vi.mocked(fs.writeFile).mockRejectedValue(systemError('ENOENT', 'open', target));
Expand Down
Loading