Skip to content

Suspend destination folder on systemic errors (disk full, stale NFS) - #384

Open
NumericalAdvantage wants to merge 2 commits into
mainfrom
fix/suspend-destination-on-systemic-errors
Open

Suspend destination folder on systemic errors (disk full, stale NFS)#384
NumericalAdvantage wants to merge 2 commits into
mainfrom
fix/suspend-destination-on-systemic-errors

Conversation

@NumericalAdvantage

@NumericalAdvantage NumericalAdvantage commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Replaces #331, which was opened in April, had drifted 49 commits behind main, and no longer merged. Same change, rebased onto current main; its CI failure was purely staleness and is gone (866 tests pass locally).

The problem

When the NFS destination filled up, mass transfer failed silently and expensively. _handle_fetched_image raises a DicomError on ENOSPC, but the generic except Exception in the per-series loop marked that volume ERROR and moved on to the next series — re-downloading from the PACS and failing on write again, for every remaining series in the task and every subsequent task for that destination.

The fix

  • New DicomFolder.suspended field (migration 0018).
  • On a systemic error — ENOSPC, ESTALE, EROFS, EIO, or a DicomError reporting out-of-disk — the processor suspends the destination folder, stops immediately, and returns FAILURE.
  • Subsequent tasks for that destination short-circuit to WARNING without touching the PACS.
  • Non-systemic errors (bad DICOM, conversion failures) keep the existing per-series continue behaviour.

Verification

  • pytest adit/mass_transfer/tests/test_processor.py122 passed
  • Full suite -m "not acceptance"866 passed, 3 xfailed
  • makemigrations --check → no changes detected
  • ruff + pyright clean

Rebase notes

Only two trivial conflicts, both resolved without behaviour change: the import block (dropped the unnecessary from __future__ import annotations; adit is Python 3.12+), and the end of the test file where main and this branch had each appended a different test section — both are kept.

Summary by CodeRabbit

Release Notes

  • New Features

    • Automatic detection of critical transfer failures including disk full, storage access issues, and I/O errors.
    • Affected destination folders are automatically suspended to prevent repeated transfer attempts.
    • System administrators receive notifications when critical failures occur.
  • Tests

    • Added comprehensive testing for error detection and destination suspension behavior.

When a systemic infrastructure error occurs during mass transfer (e.g.
ENOSPC, stale NFS handle, read-only filesystem), the processor now:

1. Stops processing remaining series immediately instead of grinding
   through them all
2. Sets DicomFolder.suspended = True on the destination
3. Sends an alert email to admins
4. Returns FAILURE for the current task

Subsequent tasks for the same destination check the suspended flag at
the start of process() and return WARNING without touching the PACS,
preventing thousands of wasted queries.

Non-systemic errors (bad DICOM data, conversion failures) continue to
be handled per-series as before.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a persistent DicomFolder.suspended state. Mass transfer now detects systemic filesystem and DICOM errors, suspends affected destinations, alerts administrators, and stops processing. Suspended destinations return a warning before transfer setup.

Changes

DICOM destination suspension

Layer / File(s) Summary
Suspension state
adit/core/models.py, adit/core/migrations/0018_dicomfolder_suspended.py
Adds the DicomFolder.suspended Boolean field with a default of False and creates the database migration.
Systemic transfer handling
adit/mass_transfer/processors.py
Classifies systemic errors, skips suspended destinations, suspends destinations after systemic failures, logs failures, alerts administrators, and re-raises systemic series errors for task-level handling.
Suspension behavior validation
adit/mass_transfer/tests/test_processor.py
Tests systemic error classification, suspension, early termination, administrator alerts, regular error handling, and pre-suspended destinations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: samuelvkwong, medihack

Sequence Diagram(s)

sequenceDiagram
  participant process
  participant DicomFolder
  participant SeriesTransfer
  participant Administrators
  process->>DicomFolder: Check suspended state
  process->>SeriesTransfer: Transfer series
  SeriesTransfer-->>process: Return systemic error
  process->>DicomFolder: Set suspended=True
  process->>Administrators: Send failure alert
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: suspending destination folders after systemic errors such as disk-full and stale-NFS failures.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/suspend-destination-on-systemic-errors

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
adit/mass_transfer/processors.py (1)

78-92: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Classify systemic errors by exception chain, not by message text.

The DicomError branch matches the substring "Out of disk space". This couples the suspension behavior to one exact message string produced elsewhere. If that message is reworded or localized, _is_systemic_error returns False. The destination is then never suspended, and the task falls back to per-series continuation on a full disk. The failure is silent.

The check also misses a systemic OSError wrapped in any other exception type, for example a conversion error raised from _convert_series.

Walk __cause__ and __context__ instead. The errno survives rewording. Keep the message test as a fallback for the case where no OSError is chained.

♻️ Proposed refactor
 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.
+
+    The exception chain is inspected so that a systemic OSError is still detected
+    when it is wrapped in a DicomError or a conversion error.
     """
-    if isinstance(err, OSError) and err.errno in _SYSTEMIC_ERRNOS:
-        return True
+    seen: set[int] = set()
+    current: BaseException | None = err
+    while current is not None and id(current) not in seen:
+        seen.add(id(current))
+        if isinstance(current, OSError) and current.errno in _SYSTEMIC_ERRNOS:
+            return True
+        current = current.__cause__ or current.__context__
     if isinstance(err, DicomError) and "Out of disk space" in str(err):
         return True
     return False

Confirm where the disk-space DicomError message is raised, and whether it chains the original OSError:

#!/bin/bash
# Locate the raise site of the disk-space message and check for exception chaining.
rg -n -C6 'Out of disk space' --type=py
# Check whether DicomError raises in the core write path preserve the cause.
rg -n -C4 'raise DicomError\(' --type=py | rg -n -C4 'from '
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 78 - 92, Update
_is_systemic_error to traverse the exception’s __cause__ and __context__ chain,
classifying any chained OSError with an errno in _SYSTEMIC_ERRNOS as systemic,
including OSErrors wrapped by other exception types. Preserve the “Out of disk
space” DicomError message check only as a fallback when no chained OSError
matches, and avoid relying on that message for errors with a non-systemic chain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@adit/core/models.py`:
- Around line 177-180: Add blank=True to the DicomFolder.suspended BooleanField
while keeping default=False and excluding null=True, so the Django admin accepts
an unchecked value and the field remains a two-state boolean.

In `@adit/mass_transfer/processors.py`:
- Around line 699-700: Update the systemic-error handling in
_transfer_grouped_series to reconcile every unattempted volume still in PENDING,
marking it ERROR or SKIPPED and emitting an explanatory log before propagating
the systemic failure. Ensure this reconciliation runs when the systemic handler
at line 413 is reached, including volumes not yet processed. Revise the
_transfer_grouped_series docstring to state its actual exception contract rather
than claiming it only raises RetriableDicomError.
- Around line 424-426: The source operator created at line 392 with
persistent=True is not being closed in the new finally block, leaving the DIMSE
association open if _discover_series or _create_pending_volumes raises an
exception. Assign the source operator at line 392 to a variable name that the
finally block can access (the same variable name used elsewhere in the function
for clarity), then add a close call for this source operator in the finally
block alongside the existing dest_operator.close() call. Since
DicomOperator.close() safely handles being called multiple times, the source
operator can be safely closed in the finally block even if it was already closed
on the success path.
- Around line 439-457: Wrap the send_mail_to_admins call in error handling to
prevent SMTP exceptions from propagating out of _suspend_destination and
altering the task result, since the suspension is already persisted.
Additionally, use a conditional update when saving folder.suspended so the mail
alert is sent only when this call transitions the folder from suspended=False to
suspended=True, preventing duplicate emails when multiple concurrent tasks hit
the same error.

In `@adit/mass_transfer/tests/test_processor.py`:
- Around line 2499-2520: Correct test_process_stops_on_systemic_error’s
docstring to describe the DicomError message branch, bind the
send_mail_to_admins patch and assert it is called once, and add a separate
end-to-end test for OSError(errno.ENOSPC, ...) that verifies failure,
destination suspension, and a single administrator alert.

---

Nitpick comments:
In `@adit/mass_transfer/processors.py`:
- Around line 78-92: Update _is_systemic_error to traverse the exception’s
__cause__ and __context__ chain, classifying any chained OSError with an errno
in _SYSTEMIC_ERRNOS as systemic, including OSErrors wrapped by other exception
types. Preserve the “Out of disk space” DicomError message check only as a
fallback when no chained OSError matches, and avoid relying on that message for
errors with a non-systemic chain.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 095128f7-b412-46cf-84ad-252fff27e2e3

📥 Commits

Reviewing files that changed from the base of the PR and between b7e6e7b and edc6e48.

📒 Files selected for processing (4)
  • adit/core/migrations/0018_dicomfolder_suspended.py
  • adit/core/models.py
  • adit/mass_transfer/processors.py
  • adit/mass_transfer/tests/test_processor.py

Comment thread adit/core/models.py
Comment on lines +177 to +180
suspended = models.BooleanField(
default=False,
help_text="Suspended destinations skip processing (e.g. disk full).",
)

Copy link
Copy Markdown
Contributor

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:

#!/bin/bash
# Find admin registrations and ModelForms bound to DicomFolder.
fd -e py | xargs rg -n -C4 'DicomFolder'

Repository: openradx/adit

Length of output: 45720


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== DicomFolder model and admin ==="
sed -n '163,182p' adit/core/models.py
sed -n '66,78p' adit/core/admin.py

echo "=== suspended/help_text references ==="
rg -n -C3 'suspended|unsuspend|Suspended' adit/core adit -g '*.py'

echo "=== BooleanField without blank in migrations ==="
python3 - <<'PY'
from pathlib import Path
import re
text = Path('adit/core/migrations/0001_initial.py').read_text()
checks = re.findall(r"models.BooleanField\([^)]*?\)", text, re.S)
for c in checks:
    print(c.replace('\n', ' ').replace('  ', ' ').strip())
PY

Repository: openradx/adit

Length of output: 23694


Add blank=True to the DicomFolder.suspended field.

DicomFolderAdmin exposes DicomFolder in Django admin. A BooleanField without blank=True is rendered as required, so an administrator cannot save the folder while suspended is unchecked. Keep this as a two-state boolean; do not add null=True, because processors.py reads it as a plain suspension gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/core/models.py` around lines 177 - 180, Add blank=True to the
DicomFolder.suspended BooleanField while keeping default=False and excluding
null=True, so the Django admin accepts an unchecked value and the field remains
a two-state boolean.

Source: Coding guidelines

Comment on lines 424 to 426
finally:
if dest_operator:
dest_operator.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the source operator in the new finally block.

The new finally closes only dest_operator. The source operator is created at line 392 with persistent=True and is closed at line 396 on the success path, or inside _transfer_grouped_series per study. If _discover_series at line 395 or _create_pending_volumes at line 399 raises, neither close runs. The persistent DIMSE association to the source PACS stays open until the worker process exits.

_discover_series raises DicomError on its own paths, for example the window-too-small case at line 1064. So this path is reachable.

Bind the operator to a variable that the finally block can see, then close it there. DicomOperator.close() already swallows its own exceptions, so a double close is safe.

🔧 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 finally block observes it:

operator = DicomOperator(source_node.dicomserver, persistent=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 424 - 426, The source operator
created at line 392 with persistent=True is not being closed in the new finally
block, leaving the DIMSE association open if _discover_series or
_create_pending_volumes raises an exception. Assign the source operator at line
392 to a variable name that the finally block can access (the same variable name
used elsewhere in the function for clarity), then add a close call for this
source operator in the finally block alongside the existing
dest_operator.close() call. Since DicomOperator.close() safely handles being
called multiple times, the source operator can be safely closed in the finally
block even if it was already closed on the success path.

Comment on lines +439 to +457
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.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  1. send_mail_to_admins performs SMTP I/O with no error handling. If the mail backend is unavailable or raises, the exception propagates out of _suspend_destination, then out of the except Exception handler at line 412. The caller loses the intended FAILURE result dict and the task fails with an unhandled exception instead. The suspension is already persisted at that point, so the two outcomes diverge. Wrap the mail call so an alert failure cannot change the task result.

  2. Mass transfer runs many partition tasks in parallel against one destination. Every in-flight task that hits the same full disk calls _suspend_destination. Each call saves the row again and sends another administrator email. A single disk-full event then produces one email per in-flight partition. Use a conditional update and alert only when this call performed the transition.

🔧 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 439 - 457, Wrap the
send_mail_to_admins call in error handling to prevent SMTP exceptions from
propagating out of _suspend_destination and altering the task result, since the
suspension is already persisted. Additionally, use a conditional update when
saving folder.suspended so the mail alert is sent only when this call
transitions the folder from suspended=False to suspended=True, preventing
duplicate emails when multiple concurrent tasks hit the same error.

Comment on lines +699 to +700
if _is_systemic_error(err):
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 _transfer_grouped_series. Volumes that were never attempted keep Status.PENDING. _build_task_summary is never reached, so nothing reconciles them.

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 WARNING. The orphaned PENDING volumes then stay in the UI indefinitely and misreport the partition as still in progress.

Mark the unattempted volumes as ERROR or SKIPPED with an explanatory log when the systemic handler at line 413 runs.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 699 - 700, Update the
systemic-error handling in _transfer_grouped_series to reconcile every
unattempted volume still in PENDING, marking it ERROR or SKIPPED and emitting an
explanatory log before propagating the systemic failure. Ensure this
reconciliation runs when the systemic handler at line 413 is reached, including
volumes not yet processed. Revise the _transfer_grouped_series docstring to
state its actual exception contract rather than claiming it only raises
RetriableDicomError.

Comment on lines +2499 to +2520
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()

Copy link
Copy Markdown
Contributor

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

Correct the docstring and assert that the alert is sent.

Two gaps in this test:

  1. The docstring says "When export raises ENOSPC". The side effect raises DicomError("Out of disk space while trying to save 'test.dcm'."), which exercises the message branch of _is_systemic_error, not the errno branch. No integration test drives a real OSError(errno.ENOSPC, ...) through process(). Fix the docstring, and add a case that raises OSError(errno.ENOSPC, "No space left on device") so the errno branch is covered end to end.

  2. Line 2515 patches send_mail_to_admins but never asserts on the mock. The PR states that a systemic error sends an alert to administrators. That requirement is currently untested. Bind the patch and assert the call.

The patch target also depends on the function-local import at processors.py line 449. If that import moves to module scope, this patch stops intercepting and the test still passes. Asserting the call makes that regression visible.

🧪 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/tests/test_processor.py` around lines 2499 - 2520, Correct
test_process_stops_on_systemic_error’s docstring to describe the DicomError
message branch, bind the send_mail_to_admins patch and assert it is called once,
and add a separate end-to-end test for OSError(errno.ENOSPC, ...) that verifies
failure, destination suspension, and a single administrator alert.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant