FEAT: Propagate error messages from simulation methods to database - #107
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end propagation of simulation error messages (including non-zero container exit codes and structured JSON errors) into persisted Simulation/SimulationRun records so the frontend can display meaningful failure reasons.
Changes:
- Detect non-zero simulation container exit codes and attempt to extract structured error messages from the result JSON.
- Persist
errorMessageonSimulationandSimulationRun, and expose it via Marshmallow schemas. - Extend
CloudExecutor’s completion stub to carry an exit code and return more informative logs.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| app/services/simulation_service.py | Adds exit-code checking, structured error extraction, and persists error messages on failures. |
| app/services/executors/cloud_executor.py | Tracks simulation failure via JSON "error" and surfaces an exit code through _CompletedJob. |
| app/schemas/simulation_schema.py | Exposes errorMessage in API responses for simulations and runs. |
| app/models/SimulationRun.py | Adds errorMessage column to persist run-level failures. |
| app/models/Simulation.py | Adds errorMessage column to persist simulation-level failures. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _poll_until_complete( | ||
| self, | ||
| remote_json_path: str, | ||
| local_uploads_dir: str, | ||
| remote_app_dir: str, | ||
| remote_sandbox_path: str, | ||
| remote_tar_path: Optional[str] = None, | ||
| ) -> bool: | ||
| """Adaptively poll the remote job until all results reach 100 % progress. | ||
| ) -> tuple[bool, int]: | ||
| """Adaptively poll the remote job until all results reach 100 % progress or an error occurs. |
| Returns: | ||
| bool: ``True`` if outputs were collected and the remote workspace | ||
| was cleaned up successfully; ``False`` if an error occurred | ||
| during :meth:`_collect_outputs_and_cleanup`. | ||
| tuple[bool, int]: A tuple of (success, exit_code) where: | ||
| - success: ``True`` if outputs were collected and cleaned up successfully | ||
| - exit_code: 0 for success, 1 for error in simulation | ||
| """ |
d27543e to
b7bc0b2
Compare
- Except explicitly raised error from the simulation methods and propagate them into the database - Except unexpected errors and add them to the log, propagate general error message ext
The model and schema for the error messages propagated from the simulation method
b7bc0b2 to
fdad4d4
Compare
Read error.message from JSON produced by the simulation container (or moved there by a remote executor) and use it when present. If the JSON is unreadable or contains no message, fall back to a generic message including the container exit code.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
app/services/simulation_service.py:571
- The "unexpected" and "failed to initialize" error paths include the raw exception string in the message persisted to the DB / returned to the client, which can leak internal details. Also, initialization failures are surfaced as HTTP 400 even though they are server-side errors (should be 500).
# Unexpected errors - log full details but show generic message
error_details = traceback.format_exc()
logger.error(f"Unexpected simulation error:\n{error_details}")
error_msg = f"An unexpected error occurred: {str(ex)}"
app/services/simulation_service.py:436
- Cancellation from CloudExecutor uses exit code 130, but this block treats any non-zero StatusCode as a RuntimeError. That bypasses the cancel-flag handling later in the function and will mark cancelled runs as Status.Error instead of Status.Cancelled.
exit_code = container_result["StatusCode"]
if exit_code != 0:
# Try to read a structured error written by the simulation container
# (or moved into place by the cloud executor on remote failure).
# If the container exits without writing {"error": {"message": ...}}
app/services/simulation_service.py:565
- New behavior is introduced here (propagating explicit RuntimeError messages into simulation_run.errorMessage/simulation.errorMessage and handling non-zero container exit codes). The existing unit/integration tests for run_solver / executors should be updated or extended to cover: (1) non-zero StatusCode returning a structured JSON error message, (2) cancellation exit code 130 mapping to Status.Cancelled, and (3) the expected shape of container.wait() results (dict with StatusCode).
except RuntimeError as ex:
# These are errors explicitly raised in the simulation-method
# including a meaningful error message.
# Propagate error messages to the database (frontend).
error_msg = str(ex)
logger.error(f"Simulation error: {error_msg}")
simulation_run.status = Status.Error
simulation_run.errorMessage = error_msg
simulation.status = Status.Error
simulation.errorMessage = error_msg
session.commit()
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
the StatusCode was previously ignored
4a39aac to
ec326f7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (7)
app/services/simulation_service.py:576
- This is not a generic frontend message because
str(ex)can include filesystem paths, database details, or other internal data. The PR explicitly requires unexpected failures to use a generic message; keep the traceback in the log but do not persist exception text to the API-facing field.
error_msg = f"An unexpected error occurred: {str(ex)}"
app/models/Simulation.py:26
- This model change has no accompanying Alembic migration.
db.create_all()only creates missing tables and does not add columns to existingsimulationstables, so upgraded deployments will fail when ORM queries referenceerrorMessage. Add a migration that adds this nullable column.
errorMessage = db.Column(db.String, nullable=True)
app/services/executors/cloud_executor.py:830
- When output download or cleanup fails,
_poll_until_completereturns(False, 0), and this return preserves status code 0.run_solvertherefore treats a failed output collection as successful and can mark the simulation completed. Convert this case to a non-zero job status while retaining the cleanup-specific log message.
elif not success:
logs_output = "Cloud job completed but cleanup failed"
else:
logs_output = "Cloud job completed successfully"
return _CompletedJob(exit_code=exit_code, logs_output=logs_output)
app/models/SimulationRun.py:22
- This column also needs to be added to existing
simulationRunstables by an Alembic migration. Updating the ORM model alone leaves deployed databases unchanged, causing queries and writes through this model to fail after rollout.
errorMessage = db.Column(db.String, nullable=True)
app/services/simulation_service.py:558
- This catches every built-in
RuntimeError, not only a user-facing simulation-method error. For example, the export failures at lines 512/533 and the local mount failure inlocal_executor.py:44also reach this handler and expose their internal text. Raise and catch a dedicated exception only for the structured container error, allowing unrelated runtime failures to use the generic handler.
except RuntimeError as ex:
app/services/simulation_service.py:568
- Once this value is set, a retry never clears it:
start_solver_taskresets theSimulationstatus at lines 261-269, and the success path setsCompleted, but neither resetserrorMessage. A successful rerun will therefore still serialize the previous failure. Clear the simulation error when starting a new run (and/or when marking it successful).
simulation.status = Status.Error
simulation.errorMessage = error_msg
app/services/simulation_service.py:586
error_msgis only logged here, so failures outside the inner block never propagate an error status/message. For example, if the linkedSimulationcannot be loaded, the handler leaves the run at its last committed state. When aSimulationRunhas been loaded, update and commit its error state here; updateSimulationas well when available.
except Exception as ex:
session.rollback()
error_msg = f"Failed to initialize simulation: {str(ex)}"
logger.error(error_msg)
| exit_code = container_result["StatusCode"] | ||
| if exit_code != 0: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
app/services/executors/cloud_executor.py:573
- A completed remote simulation is still reported with exit code 0 when output download or cleanup fails.
_collect_outputs_and_cleanup()returnsFalsefor SFTP download failures as well as cleanup failures, sorun_solver()will mark the run Completed even though its artifacts may be missing. Return a non-zero exit code whensuccessis false.
return (success, 0) # Exit code 0 for success
app/services/simulation_service.py:598
- This client-facing message includes the raw exception text, despite the PR requirement that unexpected failures use a generic message. Exception strings can expose internal paths, connection details, or other implementation data; keep the traceback in server logs but persist only a fixed generic message, and update the corresponding test expectation.
error_msg = f"An unexpected error occurred: {str(ex)}"
app/models/SimulationRun.py:22
- These new persisted columns have no database migration.
create_all()only creates missing tables and does not add columns to existing ones, while the production entrypoint does not run database creation at all; deployed databases will therefore fail when these attributes are queried or written. Add an upgrade that adds nullableerrorMessagecolumns to bothsimulationsandsimulationRuns.
errorMessage = db.Column(db.String, nullable=True)
app/models/Simulation.py:26
- This value is never cleared when
start_solver_task()reruns an existing Simulation: that path resets the status to Created and creates a fresh SimulationRun, but the Simulation retains its previous errorMessage. After a successful retry the API can therefore serialize an old failure message alongside Completed status. Clearsimulation.errorMessagewhen starting a new run.
errorMessage = db.Column(db.String, nullable=True)
| exit_code = container_result["StatusCode"] | ||
| cancelled = exit_code == 137 and os.path.exists(cancel_flag_path) | ||
|
|
||
| if exit_code != 0 and not cancelled: |
Hi @SilvinWillemsen and @mberz, This PR fixes #184 and is related to issue choras-org/CHORAS#62. > Prerequisite: This PR requires choras-org/backend#107. > Note: This PR includes #166, #167, #169, #172, #177, #178, #181, #182, and #183.
### Proposed changes - raised Exceptions are written to the json file returned to the backend - Uses choras-org/backend#107
- raised Exceptions are written to the json file returned to the backend - Uses choras-org/backend#107
- raised Exceptions are written to the json file returned to the backend - Uses choras-org/backend#107
Proposed changes
requires choras-org/frontend-v2#185