Skip to content

Commit a219b2d

Browse files
UN-2901 [FIX] Prevent invalid status updates (EXECUTING/ERROR) from duplicate file processing runs (#1606)
* UN-2901 [FIX] Prevent invalid status updates (EXECUTING/ERROR) from duplicate file processing runs Fixes race condition where late-arriving workers overwrite COMPLETED status with invalid EXECUTING or ERROR states, causing files to appear failed/stuck even though processing succeeded. Changes: - FileAPIClient: Fixed URL construction and method call bugs - Fresh DB validation: Check current status before updating to EXECUTING - Grace period optimization: Early exit when duplicate detected during tool polling - File count accuracy: Include skipped files in total_files calculation Impact: Files now correctly maintain COMPLETED status; no duplicate processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * coderabbit commnets addressed --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91f17dc commit a219b2d

7 files changed

Lines changed: 507 additions & 157 deletions

File tree

unstract/core/src/unstract/core/data_models.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -861,10 +861,10 @@ class WorkflowFileExecutionData:
861861
id: str | uuid.UUID
862862
workflow_execution_id: str | uuid.UUID
863863
file_name: str
864-
file_path: str
865864
file_size: int
866865
file_hash: str
867866
status: str = ExecutionStatus.PENDING.value
867+
file_path: str | None = None
868868
provider_file_uuid: str | None = None
869869
mime_type: str = ""
870870
fs_metadata: dict[str, Any] = field(default_factory=dict)
@@ -883,8 +883,7 @@ def __post_init__(self):
883883
# Validate required fields
884884
if not self.file_name:
885885
raise ValueError("file_name is required")
886-
if not self.file_path:
887-
raise ValueError("file_path is required")
886+
# file_path is optional - None for API workflows or when fetching status only
888887
# file_hash can be empty initially - gets populated during file processing with SHA256 hash
889888

890889
def to_dict(self) -> dict[str, Any]:
@@ -904,7 +903,7 @@ def from_dict(cls, data: dict[str, Any]) -> "WorkflowFileExecutionData":
904903
id=data["id"],
905904
workflow_execution_id=data["workflow_execution_id"],
906905
file_name=data["file_name"],
907-
file_path=data["file_path"],
906+
file_path=data.get("file_path"),
908907
file_size=data.get("file_size", 0),
909908
file_hash=data["file_hash"],
910909
status=data.get("status", ExecutionStatus.PENDING.value),

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,29 @@ def poll_tool_status(
160160
).total_seconds()
161161

162162
if not_found_duration < POLL_NOT_FOUND_GRACE_PERIOD:
163+
# RACE CONDITION OPTIMIZATION: Check if another worker completed this file
164+
# This avoids waiting full grace period when work is already done
165+
tracker = FileExecutionStatusTracker()
166+
current_tracker_data = tracker.get_data(
167+
self.execution_id, file_execution_id
168+
)
169+
if (
170+
current_tracker_data
171+
and current_tracker_data.stage_status.stage
172+
== FileExecutionStage.COMPLETED
173+
):
174+
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}"
177+
)
178+
# Break with ERROR to stop further processing (destination, finalization)
179+
# This is not a real error - the file was successfully processed by another worker
180+
response = self._create_run_response(
181+
status=RunnerContainerRunStatus.ERROR,
182+
error="File already completed by another worker (duplicate run avoided)",
183+
)
184+
break
185+
163186
logger.debug(
164187
f"Within polling grace period ({not_found_duration:.1f}s/{POLL_NOT_FOUND_GRACE_PERIOD}s) - "
165188
f"continuing poll for execution_id: {self.execution_id}, file_execution_id: {file_execution_id}"
@@ -300,11 +323,6 @@ def call_tool_handler(
300323
file_execution_data=file_execution_data,
301324
)
302325
self._update_stage_status_for_tool_execution(file_execution_id, response)
303-
self._update_stage_status(
304-
status=FileExecutionStageStatus.IN_PROGRESS,
305-
stage=FileExecutionStage.FINALIZATION,
306-
file_execution_id=file_execution_id,
307-
)
308326
elif stage.is_before(FileExecutionStage.TOOL_EXECUTION):
309327
self._update_stage_status(
310328
status=FileExecutionStageStatus.SUCCESS,
@@ -319,11 +337,6 @@ def call_tool_handler(
319337
retry_count=retry_count,
320338
)
321339
self._update_stage_status_for_tool_execution(file_execution_id, response)
322-
self._update_stage_status(
323-
status=FileExecutionStageStatus.IN_PROGRESS,
324-
stage=FileExecutionStage.FINALIZATION,
325-
file_execution_id=file_execution_id,
326-
)
327340
else:
328341
logger.warning(
329342
f"File execution data stage {file_execution_data.stage_status.stage} is after tool execution for execution_id: {self.execution_id} and file_execution_id: {file_execution_id}"

unstract/workflow-execution/src/unstract/workflow_execution/execution_file_handler.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,28 @@ def add_metadata_to_volume(
128128
metadata_path = self.metadata_file
129129
if not metadata_path:
130130
raise FileMetadataJsonNotFound()
131+
132+
# Check if metadata file already exists - skip to avoid overwriting tool data
133+
# This prevents Worker 2 from destroying tool_metadata written by Worker 1's tool container
134+
try:
135+
self.get_workflow_metadata()
136+
logger.info(
137+
f"Metadata file already exists for file_execution_id {file_execution_id}. "
138+
f"Skipping creation to preserve tool data and avoid race conditions."
139+
)
140+
return # Exit early - don't touch existing file
141+
except (FileMetadataJsonNotFound, FileNotFoundError):
142+
# Normal case - metadata file doesn't exist yet, proceed with creation
143+
logger.info(
144+
f"Creating new metadata file for file_execution_id {file_execution_id}"
145+
)
146+
except Exception:
147+
# Unexpected errors only (e.g., permission issues, S3 connection errors)
148+
logger.exception(
149+
f"Error checking metadata existence for file_execution_id {file_execution_id}. "
150+
f"Proceeding with creation."
151+
)
152+
131153
filename = os.path.basename(input_file_path)
132154
content = {
133155
MetaDataKey.SOURCE_NAME: filename,

0 commit comments

Comments
 (0)