Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/api/src/cora/api/_capture_scan_ingestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@
`{UserLastName}-{ProposalNumber}`; see
`run.aggregates.run.capture_path`'s module docstring) and this log sink
is not the vault: it cannot be erased. Every log line here carries
`run_id` and `capture_code` only, never the path and never an
`run_id` and `capture_code` only (the `invalid_scan_file` line also
carries `reason`, the closed `ScanFileInvalidReason` enum, which by
construction can never embed a path), never the path and never an
exception's rendered message (`InvalidScanFileError`'s text embeds the
locator via `repr()`), mirroring `_run_translator.py`'s identical rule for
the same value.
Expand Down Expand Up @@ -436,16 +438,17 @@ async def _ingest_one(self, candidate: ScanIngestCandidate) -> _Outcome:
existing_dataset_id=str(exc.existing_dataset_id),
)
return _Outcome.SKIP
except InvalidScanFileError:
except InvalidScanFileError as exc:
# Never `str(exc)`: the message embeds the locator via
# `repr()` (see this module's own docstring). The class
# alone already says "structurally incomplete", "unreadable",
# or "no timestamp"; that's enough for an operator to act on
# without the path.
# `repr()` (see this module's own docstring). `exc.reason`
# is the closed, non-PII `ScanFileInvalidReason` enum, safe
# to log because it can never carry a path; that's enough
# for an operator to act on without the path.
_log.warning(
"capture_scan_ingestor.invalid_scan_file",
capture_code=candidate.capture_code,
run_id=str(candidate.run_id),
reason=exc.reason.value,
)
return _Outcome.SKIP
except (
Expand Down
111 changes: 111 additions & 0 deletions apps/api/src/cora/data/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,106 @@
"BC-application-layer errors").
"""

from enum import StrEnum


class ScanFileInvalidReason(StrEnum):
"""Why `InvalidScanFileError` refused a scan file, discriminated from
the message text so a caller can branch on cause without parsing a
string or ever touching the path the message may (redacted or not)
embed.

One member per raise site in `ingest_scan.handler`; none merged.
Every pair considered kept a real diagnostic distinction: the two
`captured_at` members have opposite remedies (drop the supplied
value versus supply one); `UNREADABLE` and `DIGEST_UNREACHABLE` are
distinct pipeline stages behind distinct ports, and collapsing them
would lose which pass failed; `LOCATOR_UNRESOLVED` and
`LOCATOR_MISSING_FILENAME` fail for different reasons at different
call sites.

`is_transient` is the machine-readable permanent/transient split:
true when retrying the SAME command unmodified could plausibly
succeed once the file's writer finishes; false when nothing about
the file changing can help, because the fix requires the caller to
change the request, the file, or the configuration. Each
classification below is cited from the code that justifies it, not
from intuition.

- `LOCATOR_UNRESOLVED` (permanent): `resolve_capture_path_locator`
returns `None` only for "malformed locator; no vault row at
all; a row that exists but not at the location the locator
names; filename mismatch" (its own docstring). The sweep's
candidate query only selects vault rows that already exist, so
a failure here is drift or misconfiguration, not a file still
arriving.
- `UNREADABLE` (transient): `ScanReader`'s port docstring: "may be
transient: a half-transferred file on the analysis tier is an
expected observable, and the caller decides whether to retry."
- `UNRECOGNIZED` (permanent): the same `ScanReader` docstring
pointedly withholds the "may be transient" language from this
variant; it fires only when the layout's one mandatory dataset
is entirely absent, a layout verdict rather than an I/O timing
issue.
- `STRUCTURALLY_INCOMPLETE` (permanent): `DataExchangeScanReader`
documents the absent rotation-angle dataset as meaning
post-processing "has not completed (not yet run, failed
permanently... or a scan that produced zero projections)". The
sweep only ever considers terminal runs (`status IN
('Completed', 'Aborted')`), which narrows but does not fully
eliminate the "not yet run" sub-case this member bundles
together with the two permanent ones; whoever builds the
finality-axis work the port docstring defers should revisit
this member first.
- `DIGEST_UNREACHABLE` (transient): reuses `ChecksumVerifier`'s
`Unreachable`, whose docstring says outright "(transient)".
- `CHANGED_WHILE_READING` (transient): fires only when size or
mtime moved between two stat snapshots taken moments apart,
which by construction means a writer touched the file during
the read window.
- `CAPTURED_AT_AMBIGUOUS` (permanent): the remedy is to drop the
supplied value; the CALLER's request must change, not the file.
- `CAPTURED_AT_MISSING` (permanent): the remedy requires an
operator to supply `captured_at` manually. `CaptureScanIngestor`
(the only automated caller) never passes `captured_at`, so this
can never self-resolve through automated retry.
- `LOCATOR_MISSING_FILENAME` (permanent): the locator names a
directory, not a file; a caller/configuration defect, not a
file-readiness one.

Member name SCREAMING_SNAKE; string value PascalCase, matching
`AttestationKind`'s house style. Values are plain enum labels only
and must never embed, interpolate, or derive from a filesystem
path, so they stay safe to log and persist forever.
"""

LOCATOR_UNRESOLVED = "LocatorUnresolved"
UNREADABLE = "Unreadable"
UNRECOGNIZED = "Unrecognized"
STRUCTURALLY_INCOMPLETE = "StructurallyIncomplete"
DIGEST_UNREACHABLE = "DigestUnreachable"
CHANGED_WHILE_READING = "ChangedWhileReading"
CAPTURED_AT_AMBIGUOUS = "CapturedAtAmbiguous"
CAPTURED_AT_MISSING = "CapturedAtMissing"
LOCATOR_MISSING_FILENAME = "LocatorMissingFilename"

@property
def is_transient(self) -> bool:
"""See the class docstring for the per-member citation backing
this split; kept as a property (not a bare frozenset check
inline at each call site) so the transient/permanent verdict
has exactly one place to change."""
return self in _TRANSIENT_SCAN_FILE_INVALID_REASONS


_TRANSIENT_SCAN_FILE_INVALID_REASONS = frozenset(
{
ScanFileInvalidReason.UNREADABLE,
ScanFileInvalidReason.DIGEST_UNREACHABLE,
ScanFileInvalidReason.CHANGED_WHILE_READING,
}
)


class InvalidScanFileError(ValueError):
"""The file at the ingest locator cannot be ingested as commanded.
Expand All @@ -28,10 +128,21 @@ class InvalidScanFileError(ValueError):
parseable file value (ambiguous). The message always names the
remedy, because the operator holding it is the only one who can act.

`reason` carries the same cause as a closed, non-PII
`ScanFileInvalidReason` a caller can branch on (whether to keep
retrying, for instance) without parsing `message` or risking a path
it may embed; see that enum for the permanent/transient split.
`message` remains the sole content of `str(exc)`, unchanged, so
every existing catcher that ignores `reason` behaves identically.

Subclasses ValueError so the shared schema validator can raise it
directly for the declared evidence shape.
"""

def __init__(self, message: str, *, reason: ScanFileInvalidReason) -> None:
super().__init__(message)
self.reason = reason


class UnauthorizedError(Exception):
"""The Authorize port denied the command."""
Expand Down
29 changes: 19 additions & 10 deletions apps/api/src/cora/data/features/ingest_scan/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
ProducingRunNotFoundError,
)
from cora.data.aggregates.distribution import DistributionSupplyNotFoundError
from cora.data.errors import InvalidScanFileError, UnauthorizedError
from cora.data.errors import InvalidScanFileError, ScanFileInvalidReason, UnauthorizedError
from cora.data.features.ingest_scan.command import IngestScan
from cora.data.ports.checksum_computer import ChecksumComputer
from cora.data.ports.checksum_verifier import Unreachable
Expand Down Expand Up @@ -209,7 +209,8 @@ async def handler(
if resolved_locator is None:
raise InvalidScanFileError(
"scan file locator could not be resolved: the referenced "
"run's capture path is missing or no longer matches."
"run's capture path is missing or no longer matches.",
reason=ScanFileInvalidReason.LOCATOR_UNRESOLVED,
)
# Whether resolution actually substituted a DIFFERENT string:
# `described.reason` / `computed.error_detail` below come from
Expand All @@ -230,19 +231,22 @@ async def handler(
raise InvalidScanFileError(
"scan file is not readable"
+ _redacted_suffix(described.reason, redact=locator_was_resolved)
+ ". If the file is still transferring, retry once it has arrived."
+ ". If the file is still transferring, retry once it has arrived.",
reason=ScanFileInvalidReason.UNREADABLE,
)
if isinstance(described, Unrecognized):
raise InvalidScanFileError(
"not a recognizable scan file"
+ _redacted_suffix(described.reason, redact=locator_was_resolved)
+ _redacted_suffix(described.reason, redact=locator_was_resolved),
reason=ScanFileInvalidReason.UNRECOGNIZED,
)
if not described.structurally_complete:
raise InvalidScanFileError(
"scan file is structurally incomplete: the rotation-angle "
"dataset is absent, meaning post-processing has not "
"completed (not yet run, failed, or zero projections). "
"Ingest the file once its writer has finished with it."
"Ingest the file once its writer has finished with it.",
reason=ScanFileInvalidReason.STRUCTURALLY_INCOMPLETE,
)

captured_at, captured_at_source = _resolve_captured_at(command, described)
Expand All @@ -255,13 +259,15 @@ async def handler(
if isinstance(computed, Unreachable):
raise InvalidScanFileError(
"could not digest scan file"
+ _redacted_suffix(computed.error_detail, redact=locator_was_resolved)
+ _redacted_suffix(computed.error_detail, redact=locator_was_resolved),
reason=ScanFileInvalidReason.DIGEST_UNREACHABLE,
)
if (computed.byte_size, computed.mtime_ns) != (described.byte_size, described.mtime_ns):
raise InvalidScanFileError(
"scan file changed while being read (size or mtime moved "
"between the structural read and the digest pass). It is "
"still being written or transferred; retry once it is final."
"still being written or transferred; retry once it is final.",
reason=ScanFileInvalidReason.CHANGED_WHILE_READING,
)

# 3. Natural-key duplicate check: the digest, not the uri, since
Expand Down Expand Up @@ -414,7 +420,8 @@ def _resolve_captured_at(command: IngestScan, described: Description) -> tuple[A
f"captured_at was supplied but the file carries its own "
f"parseable timestamp ({described.captured_at_raw}, from "
f"{described.captured_at_source}). Drop the supplied value; "
f"the file's own timestamp always wins."
f"the file's own timestamp always wins.",
reason=ScanFileInvalidReason.CAPTURED_AT_AMBIGUOUS,
)
return described.captured_at, described.captured_at_source
if command.captured_at is not None:
Expand All @@ -427,7 +434,8 @@ def _resolve_captured_at(command: IngestScan, described: Description) -> tuple[A
raise InvalidScanFileError(
f"the file's acquisition timestamp is {detail}. Supply captured_at "
f"with the operator-asserted capture time (logbook, folder date) to "
f"ingest this file; CORA never fabricates one."
f"ingest this file; CORA never fabricates one.",
reason=ScanFileInvalidReason.CAPTURED_AT_MISSING,
)


Expand Down Expand Up @@ -470,7 +478,8 @@ def _filename_of(locator: str) -> str:
if not name:
raise InvalidScanFileError(
"the locator carries no filename to name the Dataset after; "
"point it at the scan file itself, not a directory."
"point it at the scan file itself, not a directory.",
reason=ScanFileInvalidReason.LOCATOR_MISSING_FILENAME,
)
return name

Expand Down
46 changes: 40 additions & 6 deletions apps/api/tests/unit/api/test_capture_scan_ingestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
ProducingRunNotFoundError,
)
from cora.data.aggregates.distribution import DistributionSupplyNotFoundError
from cora.data.errors import InvalidScanFileError, UnauthorizedError
from cora.data.errors import InvalidScanFileError, ScanFileInvalidReason, UnauthorizedError
from cora.infrastructure.capture_scan_ingestor_binding import (
CaptureScanIngestorBinding,
CaptureScanIngestorLocation,
Expand Down Expand Up @@ -422,7 +422,10 @@ async def test_authorization_recovering_after_a_denial_logs_the_recovery() -> No
"raises",
[
DatasetAlreadyIngestedError(uuid4(), "deadbeef"),
InvalidScanFileError("scan file is structurally incomplete"),
InvalidScanFileError(
"scan file is structurally incomplete",
reason=ScanFileInvalidReason.STRUCTURALLY_INCOMPLETE,
),
ProducingRunNotFoundError(_RUN_ID),
AcquisitionAssetNotFoundError(_ASSET_ID),
DistributionSupplyNotFoundError(_SUPPLY_ID),
Expand Down Expand Up @@ -463,13 +466,16 @@ async def test_tick_propagates_cancellation_instead_of_swallowing_it() -> None:
@pytest.mark.unit
async def test_a_failed_ingest_never_logs_the_observed_path() -> None:
"""`observed_path` is personal data and this log sink cannot be
erased; every failure mode's log line must carry `run_id` /
`capture_code` only, never the path or an exception message that
embeds it (`InvalidScanFileError`'s text does, via `repr()`)."""
erased; every failure mode's log line must never carry the path or
an exception message that embeds it (`InvalidScanFileError`'s text
does, via `repr()`), even though `invalid_scan_file` now also
carries `reason`, the closed `ScanFileInvalidReason` enum that by
construction can never hold a path fragment."""
lookup = _ListCandidateLookup([_candidate()])
ingest_scan = _FakeIngestScan(
raises=InvalidScanFileError(
f"scan file is not readable: /local1/2BM/2026-08-{_PERSONAL_PATH_FRAGMENT}/scan_005.h5"
f"scan file is not readable: /local1/2BM/2026-08-{_PERSONAL_PATH_FRAGMENT}/scan_005.h5",
reason=ScanFileInvalidReason.UNREADABLE,
)
)
ingestor = CaptureScanIngestor(
Expand All @@ -484,6 +490,34 @@ async def test_a_failed_ingest_never_logs_the_observed_path() -> None:
assert _PERSONAL_PATH_FRAGMENT not in str(value)


@pytest.mark.unit
async def test_invalid_scan_file_log_carries_the_reason_enum_not_the_message() -> None:
"""Proves the property this change exists for: an operator reading
the warning must be able to tell WHY ingest refused without opening
the file by hand. `reason` is the enum value; the message text
stays deliberately absent (see the sibling never-logs-the-path
test)."""
lookup = _ListCandidateLookup([_candidate()])
ingest_scan = _FakeIngestScan(
raises=InvalidScanFileError(
"scan file is structurally incomplete: the rotation-angle dataset is absent",
reason=ScanFileInvalidReason.STRUCTURALLY_INCOMPLETE,
)
)
ingestor = CaptureScanIngestor(
deps=_deps(), candidate_lookup=lookup, ingest_scan=ingest_scan, bindings=_bindings()
)

with structlog.testing.capture_logs() as logs:
await ingestor.tick()

entries = [e for e in logs if e["event"] == "capture_scan_ingestor.invalid_scan_file"]
assert len(entries) == 1
assert entries[0]["reason"] == ScanFileInvalidReason.STRUCTURALLY_INCOMPLETE.value
for value in entries[0].values():
assert "rotation-angle" not in str(value)


@pytest.mark.unit
async def test_tick_with_no_location_for_the_candidate_root_skips_ingest() -> None:
"""A capture code CAN have a binding and still have no location for
Expand Down
Loading
Loading