11import asyncio
22import structlog
33import os
4+ import time as time_mod
45import pebble
56from concurrent .futures import TimeoutError
67# Deferred imports for docling to ensure clean process initialization
@@ -172,25 +173,61 @@ def process_file_sync(file_path: str) -> dict:
172173 raise e
173174
174175
175- # Use Pebble ProcessPool for robust process management (timeout = kill)
176- # Scaled to 8 workers for high-core machines (12 cores / 24 threads)
177- # We rely on deferred imports in init_worker to simulate clean state and strict thread limits
178- executor = pebble .ProcessPool (max_workers = 8 , initializer = init_worker )
179-
180176# Increase timeout to 30 minutes to accommodate large PDF books with OCR
181177TIMEOUT_SECONDS = 1800
182178
179+ # Maximum file size: 200MB — prevents OOM in worker processes
180+ MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024
181+
182+ # Lazy-initialized ProcessPool with recovery on crash
183+ _executor : pebble .ProcessPool | None = None
184+
185+
186+ def _get_executor () -> pebble .ProcessPool :
187+ """Get or create the Pebble ProcessPool, recreating if the pool is broken."""
188+ global _executor
189+ if _executor is None or not _executor .active :
190+ if _executor is not None :
191+ logger .warning ("process_pool_recreating" , reason = "pool_inactive" )
192+ _executor = pebble .ProcessPool (max_workers = 8 , initializer = init_worker )
193+ return _executor
194+
183195
184196async def handle_file_task (file_path : str ) -> list [dict ]:
185197 """
186198 Converts a document to markdown using Docling.
187199 Executes in a Pebble ProcessPool to enforce hard timeouts and kill stuck processes.
188200 """
189- logger .info ("conversion_starting" , path = file_path )
201+ file_ext = os .path .splitext (file_path )[1 ].lower ()
202+
203+ # --- Pre-flight validation ---
204+ if not os .path .isfile (file_path ):
205+ raise IngestionError (ERR_INVALID_FORMAT , f"File not found: { file_path } " )
206+
207+ file_size = os .path .getsize (file_path )
208+ if file_size == 0 :
209+ raise IngestionError (ERR_EMPTY , "File is empty (0 bytes)" )
210+ if file_size > MAX_FILE_SIZE_BYTES :
211+ raise IngestionError (
212+ ERR_INVALID_FORMAT ,
213+ f"File too large: { file_size } bytes (limit: { MAX_FILE_SIZE_BYTES } )" ,
214+ )
215+
216+ logger .info (
217+ "conversion_starting" ,
218+ operation = "handle_file_task" ,
219+ path = file_path ,
220+ file_size = file_size ,
221+ file_extension = file_ext ,
222+ )
223+ start = time_mod .monotonic ()
190224
191225 try :
226+ # Get or recreate the process pool (recovers from crashed workers)
227+ pool = _get_executor ()
228+
192229 # Schedule the task with a hard timeout managed by Pebble
193- future = executor .schedule (
230+ future = pool .schedule (
194231 process_file_sync , args = [file_path ], timeout = TIMEOUT_SECONDS
195232 )
196233
@@ -200,6 +237,15 @@ async def handle_file_task(file_path: str) -> list[dict]:
200237 if not result ["content" ].strip ():
201238 raise IngestionError (ERR_EMPTY , "File contains no text" )
202239
240+ elapsed_ms = (time_mod .monotonic () - start ) * 1000
241+ logger .info (
242+ "conversion_completed" ,
243+ operation = "handle_file_task" ,
244+ path = file_path ,
245+ duration_ms = round (elapsed_ms , 1 ),
246+ content_length = len (result ["content" ]),
247+ )
248+
203249 return [
204250 {
205251 "content" : result ["content" ],
@@ -212,20 +258,39 @@ async def handle_file_task(file_path: str) -> list[dict]:
212258 ]
213259
214260 except (TimeoutError , pebble .ProcessExpired ):
215- logger .error ("conversion_timeout_killed" , path = file_path )
261+ elapsed_ms = (time_mod .monotonic () - start ) * 1000
262+ logger .error (
263+ "conversion_timeout_killed" ,
264+ operation = "handle_file_task" ,
265+ path = file_path ,
266+ timeout_seconds = TIMEOUT_SECONDS ,
267+ duration_ms = round (elapsed_ms , 1 ),
268+ )
216269 raise IngestionError (
217270 ERR_TIMEOUT , "Processing timed out and worker process was terminated"
218271 )
219272 except IngestionError :
220273 raise
221274 except Exception as e :
275+ elapsed_ms = (time_mod .monotonic () - start ) * 1000
222276 # Check for wrapped exceptions
223277 err_msg = str (e ).lower ()
224278 if "timeout" in err_msg :
225- logger .error ("conversion_timeout_exception" , path = file_path )
279+ logger .error (
280+ "conversion_timeout_exception" ,
281+ operation = "handle_file_task" ,
282+ path = file_path ,
283+ duration_ms = round (elapsed_ms , 1 ),
284+ )
226285 raise IngestionError (ERR_TIMEOUT , "Processing timed out" )
227286
228- logger .error ("conversion_failed" , path = file_path , error = str (e ))
287+ logger .error (
288+ "conversion_failed" ,
289+ operation = "handle_file_task" ,
290+ path = file_path ,
291+ error = str (e ),
292+ duration_ms = round (elapsed_ms , 1 ),
293+ )
229294 if "password" in err_msg or "encrypted" in err_msg :
230295 raise IngestionError (ERR_ENCRYPTED , "File is password protected" )
231296 elif "format" in err_msg :
0 commit comments