Skip to content

Commit a08020a

Browse files
UN-2901 [FIX] Prevent race conditions in distributed file processing causing status corruption and premature cleanup (#1609)
This commit implements multiple defensive fixes to prevent race conditions when duplicate workers process the same file execution, which was causing: 1. COMPLETED/ERROR status being overwritten with PENDING→EXECUTING by late workers 2. Premature chord callback cleanup (FileExpired errors) while workers still processing 3. Missing FINALIZATION stage detection during grace period 4. Incorrect COMPLETED:SUCCESS status for failed executions Fixes implemented: - Pre-creation: Check FileExecutionStatusTracker (Redis) + DB before PENDING update - Grace period: Extended duplicate detection to include FINALIZATION stage - Destination: Wait for DESTINATION_PROCESSING lock release (prevents chord cleanup) - Terminal states: Handle both COMPLETED and ERROR in processor.py - Status tracking: Set COMPLETED:FAILED for failed executions (was SUCCESS) - Refactoring: Consolidated completed_ttl to method initialization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent a219b2d commit a08020a

5 files changed

Lines changed: 161 additions & 74 deletions

File tree

unstract/tool-sandbox/src/unstract/tool_sandbox/helper.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,27 @@ def poll_tool_status(
166166
current_tracker_data = tracker.get_data(
167167
self.execution_id, file_execution_id
168168
)
169+
# Check if already at FINALIZATION or COMPLETED (tool execution done)
169170
if (
170171
current_tracker_data
171172
and current_tracker_data.stage_status.stage
172-
== FileExecutionStage.COMPLETED
173+
in [
174+
FileExecutionStage.FINALIZATION,
175+
FileExecutionStage.COMPLETED,
176+
]
173177
):
178+
stage = current_tracker_data.stage_status.stage
179+
status = current_tracker_data.stage_status.status
174180
logger.warning(
175-
f"File execution COMPLETED by another worker during NOT_FOUND grace period - "
176-
f"duplicate run detected after {not_found_duration:.1f}s for execution_id: {self.execution_id}, file_execution_id: {file_execution_id}"
181+
f"File execution already at {stage}:{status} by another worker "
182+
f"during NOT_FOUND grace period - duplicate run detected after {not_found_duration:.1f}s "
183+
f"for execution_id: {self.execution_id}, file_execution_id: {file_execution_id}"
177184
)
178185
# Break with ERROR to stop further processing (destination, finalization)
179-
# This is not a real error - the file was successfully processed by another worker
186+
# This is not a real error - the file was already processed by another worker
180187
response = self._create_run_response(
181188
status=RunnerContainerRunStatus.ERROR,
182-
error="File already completed by another worker (duplicate run avoided)",
189+
error=f"File already processed by another worker (stage: {stage}:{status}, duplicate run avoided)",
183190
)
184191
break
185192

workers/file_processing/tasks.py

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,16 +1154,19 @@ def _pre_create_file_executions(
11541154
force_create=not use_file_history, # Force create when file history is disabled
11551155
)
11561156

1157-
# EARLY EXIT: Skip if file already COMPLETED in this execution
1158-
# This catches race conditions where Worker 1 completes before Worker 2's pre-create runs
1157+
# EARLY EXIT: Skip if file already in terminal state (COMPLETED/ERROR) in this execution
1158+
# This catches race conditions where Worker 1 completes/fails before Worker 2's pre-create runs
11591159
# Only checks current execution_id (respects file_history cleanup for different executions)
1160-
if (
1161-
hasattr(workflow_file_execution, "status")
1162-
and workflow_file_execution.status == ExecutionStatus.COMPLETED.value
1163-
):
1160+
if hasattr(
1161+
workflow_file_execution, "status"
1162+
) and workflow_file_execution.status in [
1163+
ExecutionStatus.COMPLETED.value,
1164+
ExecutionStatus.ERROR.value,
1165+
]:
11641166
logger.info(
1165-
f"File '{file_name}' already COMPLETED in execution {execution_id} "
1166-
f"(file_execution_id: {workflow_file_execution.id}). Skipping duplicate task creation."
1167+
f"File '{file_name}' already in terminal state: {workflow_file_execution.status} "
1168+
f"in execution {execution_id} (file_execution_id: {workflow_file_execution.id}). "
1169+
f"Skipping duplicate task creation."
11671170
)
11681171
file_identifier = f"{file_hash.provider_file_uuid}:{file_hash.file_path}"
11691172
skipped_already_completed.append(file_identifier)
@@ -1219,16 +1222,65 @@ def _pre_create_file_executions(
12191222

12201223
else:
12211224
# NORMAL FILE: Set initial status to PENDING for processing
1222-
try:
1223-
api_client.file_client.update_file_execution_status(
1224-
file_execution_id=workflow_file_execution.id,
1225-
status=ExecutionStatus.PENDING.value,
1225+
# But preserve EXECUTING status for crash recovery (don't overwrite active processing)
1226+
1227+
# RACE CONDITION FIX: Check FileExecutionStatusTracker (Redis, real-time) before updating
1228+
# This prevents late-arriving workers from overwriting COMPLETED/ERROR status with PENDING
1229+
from unstract.core.file_execution_tracker import (
1230+
FileExecutionStage,
1231+
FileExecutionStatusTracker,
1232+
)
1233+
1234+
tracker = FileExecutionStatusTracker()
1235+
current_tracker_data = tracker.get_data(
1236+
execution_id, workflow_file_execution.id
1237+
)
1238+
1239+
# Primary check: FileExecutionStatusTracker (real-time, Redis-based)
1240+
if current_tracker_data and current_tracker_data.stage_status.stage in [
1241+
FileExecutionStage.FINALIZATION,
1242+
FileExecutionStage.COMPLETED,
1243+
]:
1244+
logger.info(
1245+
f"File '{file_name}' already at {current_tracker_data.stage_status.stage} "
1246+
f"stage by another worker - skipping PENDING update "
1247+
f"(file_execution_id: {workflow_file_execution.id})"
12261248
)
1227-
except Exception as status_error:
1228-
logger.warning(
1229-
f"Failed to set initial PENDING status for file {file_name}: {status_error}"
1249+
# Skip to next file without updating status or adding to pre_created_data
1250+
continue
1251+
1252+
# Fallback check: DB status (in case Redis down or tracker expired)
1253+
current_status = getattr(workflow_file_execution, "status", None)
1254+
if current_status in [
1255+
ExecutionStatus.COMPLETED.value,
1256+
ExecutionStatus.ERROR.value,
1257+
]:
1258+
logger.info(
1259+
f"File '{file_name}' already in terminal state {current_status} "
1260+
f"(DB status) - skipping PENDING update "
1261+
f"(file_execution_id: {workflow_file_execution.id})"
1262+
)
1263+
# Skip to next file
1264+
continue
1265+
1266+
# Preserve EXECUTING for crash recovery (don't reset to PENDING)
1267+
if current_status != ExecutionStatus.EXECUTING.value:
1268+
try:
1269+
api_client.file_client.update_file_execution_status(
1270+
file_execution_id=workflow_file_execution.id,
1271+
status=ExecutionStatus.PENDING.value,
1272+
)
1273+
logger.info(f"Set initial PENDING status for file {file_name}")
1274+
except Exception as status_error:
1275+
logger.warning(
1276+
f"Failed to set initial PENDING status for file {file_name}: {status_error}"
1277+
)
1278+
# Don't fail the entire creation if status update fails
1279+
else:
1280+
logger.info(
1281+
f"File '{file_name}' already EXECUTING - preserving status for crash recovery/resume "
1282+
f"(file_execution_id: {workflow_file_execution.id})"
12301283
)
1231-
# Don't fail the entire creation if status update fails
12321284

12331285
# Add to pre_created_data for processing
12341286
pre_created_data[file_name] = PreCreatedFileData(

workers/shared/processing/files/processor.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,34 @@ def validate_workflow_file_execution(
182182
)
183183

184184
# RACE CONDITION FIX: Fetch fresh status from DB instead of using cached object
185-
# This prevents late-arriving workers from checking stale data and overwriting COMPLETED status
185+
# This prevents late-arriving workers from checking stale data and overwriting terminal status
186186
workflow_file_execution = context.api_client.get_workflow_file_execution(
187187
context.workflow_file_execution_id
188188
)
189189

190-
# Check if file execution is already completed
191-
if workflow_file_execution.status == ExecutionStatus.COMPLETED.value:
190+
# Check if file execution is already in terminal state (COMPLETED or ERROR)
191+
if workflow_file_execution.status in [
192+
ExecutionStatus.COMPLETED.value,
193+
ExecutionStatus.ERROR.value,
194+
]:
192195
logger.info(
193-
f"File already completed. Skipping execution for execution_id: {context.execution_id}, "
196+
f"File already in terminal state: {workflow_file_execution.status}. "
197+
f"Skipping execution for execution_id: {context.execution_id}, "
194198
f"file_execution_id: {workflow_file_execution.id}"
195199
)
196200

201+
# Return appropriate result based on terminal status
197202
return FileProcessingResult(
198203
file_name=context.file_name,
199204
file_execution_id=workflow_file_execution.id,
200-
success=True,
201-
error=None,
205+
success=(
206+
workflow_file_execution.status == ExecutionStatus.COMPLETED.value
207+
),
208+
error=(
209+
getattr(workflow_file_execution, "execution_error", None)
210+
if workflow_file_execution.status == ExecutionStatus.ERROR.value
211+
else None
212+
),
202213
result=getattr(workflow_file_execution, "result", None),
203214
metadata=getattr(workflow_file_execution, "metadata", None) or {},
204215
)

workers/shared/workflow/destination_connector.py

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -330,36 +330,6 @@ def _check_and_acquire_destination_lock(
330330
try:
331331
tracker = FileExecutionStatusTracker()
332332

333-
# Check if destination already processed or in progress
334-
existing_data = tracker.get_data(
335-
exec_ctx.execution_id, exec_ctx.file_execution_id
336-
)
337-
338-
if existing_data:
339-
current_stage = existing_data.stage_status.stage
340-
# Check for DESTINATION_PROCESSING, FINALIZATION, or COMPLETED
341-
if current_stage in [
342-
FileExecutionStage.DESTINATION_PROCESSING,
343-
FileExecutionStage.FINALIZATION,
344-
FileExecutionStage.COMPLETED,
345-
]:
346-
# Enhanced debug log with full context for internal debugging
347-
logger.warning(
348-
f"DUPLICATE DETECTION: File '{file_ctx.file_name}' destination already at stage "
349-
f"{current_stage.value}. execution_id={exec_ctx.execution_id}, "
350-
f"file_execution_id={exec_ctx.file_execution_id}, worker_pid={os.getpid()}. "
351-
f"Skipping duplicate processing - another worker completed this already."
352-
)
353-
# UI log removed - duplication is internal detail, not user-facing error
354-
return False # Duplicate detected
355-
356-
# Atomically acquire lock using Redis SET NX
357-
# Use helper method for consistent lock key format
358-
lock_key = tracker.get_destination_lock_key(
359-
exec_ctx.execution_id, exec_ctx.file_execution_id
360-
)
361-
lock_token = str(uuid.uuid4()) # Unique token for debugging
362-
363333
# Get TTL values from environment
364334
LOCK_TTL = int(
365335
os.environ.get("DESTINATION_PROCESSING_LOCK_TTL_IN_SECOND", 120)
@@ -368,27 +338,65 @@ def _check_and_acquire_destination_lock(
368338
os.environ.get("DESTINATION_PROCESSING_STAGE_TTL_IN_SECOND", 600)
369339
)
370340

341+
# Get lock key for atomic operations
342+
lock_key = tracker.get_destination_lock_key(
343+
exec_ctx.execution_id, exec_ctx.file_execution_id
344+
)
345+
lock_token = str(uuid.uuid4()) # Unique token for debugging
346+
347+
# STEP 1: Try to acquire lock atomically (SOURCE OF TRUTH)
348+
# This is atomic - if lock exists, another worker is processing
371349
logger.info(
372350
f"Attempting to acquire destination lock for file '{file_ctx.file_name}' "
373351
f"(lock_key={lock_key}, lock_ttl={LOCK_TTL}s, stage_ttl={STAGE_TTL}s)"
374352
)
375353

376-
# Use Redis SET NX (Set if Not eXists) for true atomic lock acquisition
377-
# Returns True if key was set (lock acquired), False if key already exists
378354
lock_acquired = tracker.redis_client.set(
379355
lock_key, lock_token, nx=True, ex=LOCK_TTL
380356
)
381357

358+
# STEP 2: If lock acquisition failed, another worker is processing - WAIT
382359
if not lock_acquired:
383-
# Enhanced debug log with full context for internal debugging
384-
logger.warning(
385-
f"DUPLICATE DETECTION: Lock acquisition failed for file '{file_ctx.file_name}'. "
386-
f"execution_id={exec_ctx.execution_id}, file_execution_id={exec_ctx.file_execution_id}, "
387-
f"lock_key={lock_key}, worker_pid={os.getpid()}. "
388-
f"Another worker holds the lock - skipping duplicate processing."
360+
logger.info(
361+
f"Lock already held by another worker for file '{file_ctx.file_name}'. "
362+
f"Waiting for lock release to prevent premature chord cleanup (max {LOCK_TTL}s)..."
389363
)
390-
# UI log removed - duplication is internal detail, not user-facing error
391-
return False
364+
365+
wait_start = time.time()
366+
max_wait = min(LOCK_TTL, 120) # Wait up to lock TTL or 120s
367+
368+
# Poll until lock released or timeout
369+
while time.time() - wait_start < max_wait:
370+
# Check if lock released
371+
if not tracker.redis_client.exists(lock_key):
372+
wait_duration = time.time() - wait_start
373+
# Lock released BEFORE timeout → Other worker finished (success or error) → Skip
374+
logger.info(
375+
f"Lock released for '{file_ctx.file_name}' after {wait_duration:.1f}s - "
376+
f"other worker completed processing. Skipping as duplicate."
377+
)
378+
return False # Skip immediately
379+
380+
time.sleep(2) # Poll every 2 seconds
381+
382+
# STEP 3: After wait, try to acquire lock again
383+
lock_acquired = tracker.redis_client.set(
384+
lock_key, lock_token, nx=True, ex=LOCK_TTL
385+
)
386+
387+
if not lock_acquired:
388+
# Still can't acquire lock (timeout or another worker grabbed it)
389+
if tracker.redis_client.exists(lock_key):
390+
logger.warning(
391+
f"Lock still held after {max_wait}s timeout for '{file_ctx.file_name}'. "
392+
f"Another worker may have acquired it. Skipping as duplicate."
393+
)
394+
else:
395+
logger.warning(
396+
f"Failed to acquire lock for '{file_ctx.file_name}' after wait. "
397+
f"Another worker may have grabbed it first. Skipping as duplicate."
398+
)
399+
return False # Skip
392400

393401
# Lock acquired successfully - now set DESTINATION_PROCESSING stage
394402
logger.info(

workers/shared/workflow/execution/service.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ def execute_workflow_for_file(
9494
destination_start_time = start_time
9595
destination_end_time = start_time
9696

97+
# Configure TTL for COMPLETED stage (shorter TTL to optimize Redis memory)
98+
completed_ttl = int(
99+
os.environ.get("FILE_EXECUTION_TRACKER_COMPLETED_TTL_IN_SECOND", 300)
100+
)
101+
97102
try:
98103
logger.info(f"Executing workflow {workflow_id} for file {file_name}")
99104

@@ -409,13 +414,6 @@ def execute_workflow_for_file(
409414
),
410415
)
411416
logger.info(f"Marked finalization as successful for {file_name}")
412-
413-
# Use shorter TTL for COMPLETED stage to optimize Redis memory
414-
completed_ttl = int(
415-
os.environ.get(
416-
"FILE_EXECUTION_TRACKER_COMPLETED_TTL_IN_SECOND", 300
417-
)
418-
)
419417
tracker.update_stage_status(
420418
execution_id=execution_id,
421419
file_execution_id=workflow_file_execution_id,
@@ -442,6 +440,17 @@ def execute_workflow_for_file(
442440
error=error_msg,
443441
),
444442
)
443+
# Set COMPLETED:FAILED to mark file as done (prevents duplicate processing)
444+
tracker.update_stage_status(
445+
execution_id=execution_id,
446+
file_execution_id=workflow_file_execution_id,
447+
stage_status=FileExecutionStageData(
448+
stage=FileExecutionStage.COMPLETED,
449+
status=FileExecutionStageStatus.FAILED,
450+
error=error_msg,
451+
),
452+
ttl_in_second=completed_ttl,
453+
)
445454
logger.info(f"Tracked failed execution for {file_name}: {error_msg}")
446455
else:
447456
# Duplicate skip - don't update any stages to avoid interfering with active worker

0 commit comments

Comments
 (0)