-
Notifications
You must be signed in to change notification settings - Fork 7
Suspend destination folder on systemic errors (disk full, stale NFS) #384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Generated by Django 6.0.3 on 2026-04-20 14:32 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('core', '0017_review_fixes'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name='dicomfolder', | ||
| name='suspended', | ||
| field=models.BooleanField(default=False, help_text='Suspended destinations skip processing (e.g. disk full).'), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import errno | ||
| import json | ||
| import logging | ||
| import secrets | ||
|
|
@@ -74,6 +75,21 @@ def from_dict(cls, d: dict) -> "FilterSpec": | |
|
|
||
| _MIN_SPLIT_WINDOW = timedelta(minutes=30) | ||
| _DELAY_BETWEEN_STUDIES = 0.5 # seconds between studies to avoid overwhelming the PACS | ||
| _SYSTEMIC_ERRNOS = (errno.ENOSPC, errno.ESTALE, errno.EROFS, errno.EIO) | ||
|
|
||
|
|
||
| def _is_systemic_error(err: Exception) -> bool: | ||
| """Return True if the error indicates a systemic infrastructure problem. | ||
|
|
||
| Systemic errors (disk full, stale NFS handle, read-only filesystem, I/O error) | ||
| affect all remaining series equally, so continuing is pointless. | ||
| """ | ||
| if isinstance(err, OSError) and err.errno in _SYSTEMIC_ERRNOS: | ||
| return True | ||
| if isinstance(err, DicomError) and "Out of disk space" in str(err): | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| # Deterministic pseudonyms use 14 characters. Random pseudonyms use 15 so the | ||
| # two modes can be distinguished by length. | ||
|
|
@@ -319,9 +335,19 @@ def process(self): | |
| "log": "Task skipped because the mass transfer app is suspended.", | ||
| } | ||
|
|
||
| destination_node = self.mass_task.destination | ||
| if ( | ||
| destination_node.node_type == DicomNode.NodeType.FOLDER | ||
| and destination_node.dicomfolder.suspended | ||
| ): | ||
| return { | ||
| "status": MassTransferTask.Status.WARNING, | ||
| "message": f"Destination '{destination_node.name}' is suspended.", | ||
| "log": "Task skipped because the destination folder is suspended.", | ||
| } | ||
|
|
||
| job = self.mass_task.job | ||
| source_node = self.mass_task.source | ||
| destination_node = self.mass_task.destination | ||
|
|
||
| if source_node.node_type != DicomNode.NodeType.SERVER: | ||
| raise DicomError("Mass transfer source must be a DICOM server.") | ||
|
|
@@ -374,18 +400,62 @@ def process(self): | |
| grouped_volumes = self._group_volumes(volumes) | ||
|
|
||
| # Transfer: fetch series grouped by study | ||
| return self._transfer_grouped_series( | ||
| operator, | ||
| grouped_volumes, | ||
| job, | ||
| pseudonymizer, | ||
| output_base, | ||
| dest_operator, | ||
| ) | ||
| try: | ||
| return self._transfer_grouped_series( | ||
| operator, | ||
| grouped_volumes, | ||
| job, | ||
| pseudonymizer, | ||
| output_base, | ||
| dest_operator, | ||
| ) | ||
| except Exception as err: | ||
| if _is_systemic_error(err): | ||
| self._suspend_destination(destination_node, job, err) | ||
| return { | ||
| "status": MassTransferTask.Status.FAILURE, | ||
| "message": f"Destination suspended: {err}", | ||
| "log": ( | ||
| "Systemic error detected. Destination folder suspended.\n" | ||
| f"{err}" | ||
| ), | ||
| } | ||
| raise | ||
| finally: | ||
| if dest_operator: | ||
| dest_operator.close() | ||
|
Comment on lines
424
to
426
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Close the source operator in the new The new
Bind the operator to a variable that the 🔧 Proposed fix dest_operator: DicomOperator | None = None
output_base: Path | None = None
+ operator: DicomOperator | None = None
if destination_node.node_type == DicomNode.NodeType.SERVER:- if dest_operator:
+ if operator:
+ operator.close()
+ if dest_operator:
dest_operator.close()Assign through the same name at line 392 so the operator = DicomOperator(source_node.dicomserver, persistent=True)🤖 Prompt for AI Agents |
||
|
|
||
| def _suspend_destination( | ||
| self, | ||
| destination_node: DicomNode, | ||
| job: MassTransferJob, | ||
| err: Exception, | ||
| ) -> None: | ||
| """Suspend a folder destination after a systemic error (e.g. disk full).""" | ||
| if destination_node.node_type != DicomNode.NodeType.FOLDER: | ||
| return | ||
|
|
||
| folder = destination_node.dicomfolder | ||
| folder.suspended = True | ||
| folder.save(update_fields=["suspended"]) | ||
|
|
||
| logger.critical( | ||
| "Destination folder '%s' suspended due to systemic error (job %d): %s", | ||
| destination_node.name, | ||
| job.pk, | ||
| err, | ||
| ) | ||
|
|
||
| from adit.core.utils.mail import send_mail_to_admins | ||
|
|
||
| send_mail_to_admins( | ||
| f"Mass transfer destination '{destination_node.name}' suspended", | ||
| f"Destination folder '{destination_node.name}' was automatically suspended " | ||
| f"due to a systemic error during mass transfer job {job.pk}.\n\n" | ||
| f"Error: {err}\n\n" | ||
| f"Please fix the underlying issue and unsuspend the folder in the admin panel.", | ||
| ) | ||
|
Comment on lines
+439
to
+457
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Contain mail failures and make the suspension idempotent. Two problems affect this alerting path:
🔧 Proposed fix folder = destination_node.dicomfolder
- folder.suspended = True
- folder.save(update_fields=["suspended"])
+ # Atomic transition so concurrent partition tasks alert only once.
+ transitioned = (
+ type(folder).objects.filter(pk=folder.pk, suspended=False).update(suspended=True) == 1
+ )
+ folder.suspended = True
+ if not transitioned:
+ return
logger.critical(
"Destination folder '%s' suspended due to systemic error (job %d): %s",
destination_node.name,
job.pk,
err,
)
from adit.core.utils.mail import send_mail_to_admins
- send_mail_to_admins(
- f"Mass transfer destination '{destination_node.name}' suspended",
- f"Destination folder '{destination_node.name}' was automatically suspended "
- f"due to a systemic error during mass transfer job {job.pk}.\n\n"
- f"Error: {err}\n\n"
- f"Please fix the underlying issue and unsuspend the folder in the admin panel.",
- )
+ try:
+ send_mail_to_admins(
+ f"Mass transfer destination '{destination_node.name}' suspended",
+ f"Destination folder '{destination_node.name}' was automatically suspended "
+ f"due to a systemic error during mass transfer job {job.pk}.\n\n"
+ f"Error: {err}\n\n"
+ f"Please fix the underlying issue and unsuspend the folder in the admin panel.",
+ )
+ except Exception:
+ logger.exception(
+ "Failed to send suspension alert for destination '%s'", destination_node.name
+ )🤖 Prompt for AI Agents |
||
|
|
||
| def _create_pending_volumes( | ||
| self, | ||
| discovered: list[DiscoveredSeries], | ||
|
|
@@ -626,6 +696,8 @@ def _transfer_single_series( | |
| ) | ||
| volume.status = MassTransferVolume.Status.ERROR | ||
| volume.log = str(err) | ||
| if _is_systemic_error(err): | ||
| raise | ||
|
Comment on lines
+699
to
+700
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Reconcile the remaining PENDING volumes after a systemic abort, and update the stale contract. The re-raise aborts the loop in A retry normally repairs this, because lines 381-384 delete and recreate the partition volumes. That repair no longer happens here. The destination is suspended by the same failure, so every later task for it returns early at line 339 with Mark the unattempted volumes as The docstring at lines 621-622 states "Never raises except for RetriableDicomError." That is no longer accurate. Update it, because callers depend on the stated contract. Do you want me to draft the reconciliation step in the systemic handler? 🤖 Prompt for AI Agents |
||
| finally: | ||
| if volume.status == MassTransferVolume.Status.PENDING: | ||
| logger.error( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import errno | ||
| import json | ||
| from datetime import date, datetime, timedelta | ||
| from pathlib import Path | ||
|
|
@@ -30,6 +31,7 @@ | |
| _birth_date_range, | ||
| _destination_base_dir, | ||
| _dicom_match, | ||
| _is_systemic_error, | ||
| _parse_int, | ||
| _series_folder_name, | ||
| _series_matches_filter, | ||
|
|
@@ -713,6 +715,7 @@ def _make_process_env( | |
| processor.mass_task.source.dicomserver = mocker.MagicMock() | ||
| processor.mass_task.destination.node_type = DicomNode.NodeType.FOLDER | ||
| processor.mass_task.destination.dicomfolder.path = str(tmp_path / "output") | ||
| processor.mass_task.destination.dicomfolder.suspended = False | ||
|
|
||
| processor.mass_task.pk = 42 | ||
| processor.mass_task.partition_key = "20240101" | ||
|
|
@@ -2462,3 +2465,135 @@ def test_process_final_attempt_all_dead_is_failure(mocker: MockerFixture, tmp_pa | |
|
|
||
| assert result["status"] == MassTransferTask.Status.FAILURE | ||
| assert "Failed: 2" in result["log"] | ||
| # Systemic error detection tests | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestIsSystemicError: | ||
| def test_enospc(self): | ||
| assert _is_systemic_error(OSError(errno.ENOSPC, "No space left on device")) | ||
|
|
||
| def test_estale(self): | ||
| assert _is_systemic_error(OSError(errno.ESTALE, "Stale file handle")) | ||
|
|
||
| def test_erofs(self): | ||
| assert _is_systemic_error(OSError(errno.EROFS, "Read-only file system")) | ||
|
|
||
| def test_eio(self): | ||
| assert _is_systemic_error(OSError(errno.EIO, "Input/output error")) | ||
|
|
||
| def test_disk_space_dicom_error(self): | ||
| assert _is_systemic_error(DicomError("Out of disk space while trying to save 'foo.dcm'.")) | ||
|
|
||
| def test_regular_oserror(self): | ||
| assert not _is_systemic_error(OSError(errno.ENOENT, "No such file")) | ||
|
|
||
| def test_regular_exception(self): | ||
| assert not _is_systemic_error(ValueError("bad value")) | ||
|
|
||
| def test_regular_dicom_error(self): | ||
| assert not _is_systemic_error(DicomError("Something else went wrong")) | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_process_stops_on_systemic_error(mocker: MockerFixture, mass_transfer_env): | ||
| """When export raises ENOSPC, the task stops immediately and the destination is suspended.""" | ||
| env = mass_transfer_env | ||
| series = [ | ||
| _make_discovered(patient_id="PAT1", series_uid="series-1"), | ||
| _make_discovered(patient_id="PAT1", series_uid="series-2"), | ||
| ] | ||
|
|
||
| processor = MassTransferTaskProcessor(env.task) | ||
| mocker.patch.object(processor, "_discover_series", return_value=series) | ||
| mocker.patch("adit.mass_transfer.processors.DicomOperator") | ||
| mocker.patch.object( | ||
| processor, | ||
| "_export_series", | ||
| side_effect=DicomError("Out of disk space while trying to save 'test.dcm'."), | ||
| ) | ||
| mocker.patch("adit.core.utils.mail.send_mail_to_admins") | ||
|
|
||
| result = processor.process() | ||
|
|
||
| assert result["status"] == MassTransferTask.Status.FAILURE | ||
| assert "suspended" in result["message"].lower() or "suspended" in result["log"].lower() | ||
|
Comment on lines
+2499
to
+2520
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the docstring and assert that the alert is sent. Two gaps in this test:
The patch target also depends on the function-local import at 🧪 Proposed fix `@pytest.mark.django_db`
def test_process_stops_on_systemic_error(mocker: MockerFixture, mass_transfer_env):
- """When export raises ENOSPC, the task stops immediately and the destination is suspended."""
+ """A disk-space DicomError stops the task immediately and suspends the destination."""
env = mass_transfer_env
series = [
_make_discovered(patient_id="PAT1", series_uid="series-1"),
_make_discovered(patient_id="PAT1", series_uid="series-2"),
]
processor = MassTransferTaskProcessor(env.task)
mocker.patch.object(processor, "_discover_series", return_value=series)
mocker.patch("adit.mass_transfer.processors.DicomOperator")
mocker.patch.object(
processor,
"_export_series",
side_effect=DicomError("Out of disk space while trying to save 'test.dcm'."),
)
- mocker.patch("adit.core.utils.mail.send_mail_to_admins")
+ send_mail_mock = mocker.patch("adit.core.utils.mail.send_mail_to_admins")
result = processor.process()
assert result["status"] == MassTransferTask.Status.FAILURE
assert "suspended" in result["message"].lower() or "suspended" in result["log"].lower()
+ send_mail_mock.assert_called_once()Add an errno-branch integration test: `@pytest.mark.django_db`
def test_process_stops_on_enospc_oserror(mocker: MockerFixture, mass_transfer_env):
"""A raw ENOSPC OSError also suspends the destination and stops the task."""
env = mass_transfer_env
processor = MassTransferTaskProcessor(env.task)
mocker.patch.object(
processor,
"_discover_series",
return_value=[_make_discovered(patient_id="PAT1", series_uid="series-1")],
)
mocker.patch("adit.mass_transfer.processors.DicomOperator")
mocker.patch.object(
processor,
"_export_series",
side_effect=OSError(errno.ENOSPC, "No space left on device"),
)
send_mail_mock = mocker.patch("adit.core.utils.mail.send_mail_to_admins")
result = processor.process()
assert result["status"] == MassTransferTask.Status.FAILURE
env.destination.refresh_from_db()
assert env.destination.suspended is True
send_mail_mock.assert_called_once()🤖 Prompt for AI Agents |
||
|
|
||
| env.destination.refresh_from_db() | ||
| assert env.destination.suspended is True | ||
|
|
||
| # Only the first series should have been attempted (second skipped due to early exit) | ||
| vols = MassTransferVolume.objects.filter(job=env.job) | ||
| error_vols = vols.filter(status=MassTransferVolume.Status.ERROR) | ||
| assert error_vols.count() == 1 | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_process_continues_on_regular_error(mocker: MockerFixture, mass_transfer_env): | ||
| """Regular errors (non-systemic) mark the volume as ERROR but continue to the next series.""" | ||
| env = mass_transfer_env | ||
| series = [ | ||
| _make_discovered(patient_id="PAT1", series_uid="series-1"), | ||
| _make_discovered(patient_id="PAT1", series_uid="series-2"), | ||
| ] | ||
|
|
||
| call_count = 0 | ||
|
|
||
| def export_first_fails(*args, **kwargs): | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| if call_count == 1: | ||
| raise DicomError("Bad DICOM data") | ||
| return (1, "", "") | ||
|
|
||
| processor = MassTransferTaskProcessor(env.task) | ||
| mocker.patch.object(processor, "_discover_series", return_value=series) | ||
| mocker.patch("adit.mass_transfer.processors.DicomOperator") | ||
| mocker.patch.object(processor, "_export_series", side_effect=export_first_fails) | ||
|
|
||
| result = processor.process() | ||
|
|
||
| # Should be WARNING (one succeeded, one failed) — NOT FAILURE | ||
| assert result["status"] == MassTransferTask.Status.WARNING | ||
|
|
||
| env.destination.refresh_from_db() | ||
| assert env.destination.suspended is False | ||
|
|
||
| # Both series were attempted | ||
| assert call_count == 2 | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_process_skips_when_destination_suspended(mocker: MockerFixture, mass_transfer_env): | ||
| """When the destination folder is suspended, process() returns WARNING immediately.""" | ||
| env = mass_transfer_env | ||
| env.destination.suspended = True | ||
| env.destination.save() | ||
|
|
||
| processor = MassTransferTaskProcessor(env.task) | ||
| discover_mock = mocker.patch.object(processor, "_discover_series") | ||
|
|
||
| result = processor.process() | ||
|
|
||
| assert result["status"] == MassTransferTask.Status.WARNING | ||
| assert "suspended" in result["message"].lower() | ||
| discover_mock.assert_not_called() | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_process_does_not_suspend_on_regular_error(mocker: MockerFixture, mass_transfer_env): | ||
| """Non-systemic errors do NOT suspend the destination.""" | ||
| env = mass_transfer_env | ||
| series = [_make_discovered(patient_id="PAT1", series_uid="series-1")] | ||
|
|
||
| processor = MassTransferTaskProcessor(env.task) | ||
| mocker.patch.object(processor, "_discover_series", return_value=series) | ||
| mocker.patch("adit.mass_transfer.processors.DicomOperator") | ||
| mocker.patch.object(processor, "_export_series", side_effect=DicomError("Export failed")) | ||
|
|
||
| result = processor.process() | ||
|
|
||
| assert result["status"] == MassTransferTask.Status.FAILURE | ||
|
|
||
| env.destination.refresh_from_db() | ||
| assert env.destination.suspended is False | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: openradx/adit
Length of output: 45720
🏁 Script executed:
Repository: openradx/adit
Length of output: 23694
Add
blank=Trueto theDicomFolder.suspendedfield.DicomFolderAdminexposesDicomFolderin Django admin. ABooleanFieldwithoutblank=Trueis rendered as required, so an administrator cannot save the folder whilesuspendedis unchecked. Keep this as a two-state boolean; do not addnull=True, becauseprocessors.pyreads it as a plain suspension gate.🤖 Prompt for AI Agents
Source: Coding guidelines