Do not stamp past references the reset could not classify

A reference whose file is on disk but whose path is under no configured root
was skipped so that a missing extra_model_paths.yaml could not strip tags off
assets that were merely out of view. But the step still finished successfully
and the version was still stamped, so restoring the root repaired nothing: the
step never ran again, and no other path re-derives an existing reference's
loader_path -- the scan only builds specs for paths it has not seen, and the
enricher computes a loader path without writing it back. The guard turned
destroying the data into never repairing it.

A step now reports whether it finished its work, and an unfinished one is not
stamped. A file that is simply gone still does not count as unfinished; that is
the scan's business under its own missing semantics.

Two ordering fixes in the scan around the same call:

The walk is handed the seeder's pause-aware checkpoint rather than a
cancel-only check. The seeder is paused while a prompt runs, so a check that
ignores pause left the reset statting the whole asset table during generation
while the status reported PAUSED.

Cancellation is checked immediately after the reset instead of after the prune.
Cancelling during a long reprojection otherwise fell through into the prune and
temp reconciliation, doing state-mutating work on a scan that had been told to
stop.
This commit is contained in:
Simon Pinfold
2026-08-18 11:34:34 -07:00
parent eec285286e
commit e7eb7832b9
6 changed files with 213 additions and 7 deletions

View File

@@ -550,7 +550,15 @@ class _AssetSeeder:
return
# Must precede the prune and the scan: both read and extend these rows.
run_pending_semantics_steps(interrupt_check=self._is_cancelled)
# _check_pause_and_cancel, not _is_cancelled: a pause has to suspend
# the walk between batches, or it keeps statting the whole table
# while the seeder reports PAUSED and a prompt runs.
run_pending_semantics_steps(interrupt_check=self._check_pause_and_cancel)
if self._is_cancelled():
logging.info("Asset scan cancelled during semantics reset")
cancelled = True
return
if self._prune_first:
all_prefixes = get_owned_prefixes()

View File

@@ -19,6 +19,7 @@ from app.assets.semantics.step import (
InterruptCheck,
SemanticsStep,
SemanticsStepInterrupted,
StepResult,
)
from app.database.db import can_create_session, create_session
@@ -28,6 +29,7 @@ __all__ = [
"InterruptCheck",
"SemanticsStep",
"SemanticsStepInterrupted",
"StepResult",
"run_pending_semantics_steps",
]
@@ -88,6 +90,17 @@ def run_pending_semantics_steps(interrupt_check: InterruptCheck | None = None) -
)
return applied
if not summary.complete:
logging.info(
"Asset semantics step %d (%s) left work unfinished, staying at "
"version %d so it runs again: %s",
step.version,
step.description,
stored_version + applied,
summary,
)
return applied
with create_session() as session:
set_semantics_version(session, step.version)
session.commit()

View File

@@ -32,7 +32,11 @@ from app.assets.database.queries.semantics import (
get_tag_origins_by_reference,
)
from app.assets.helpers import normalize_tags
from app.assets.semantics.step import InterruptCheck, SemanticsStepInterrupted
from app.assets.semantics.step import (
InterruptCheck,
SemanticsStepInterrupted,
StepResult,
)
from app.assets.services.file_utils import verify_file_unchanged
from app.assets.services.path_utils import (
compute_loader_path,
@@ -45,7 +49,7 @@ _ROWS_PER_TRANSACTION = 500
@dataclass
class ReprojectionSummary:
class ReprojectionSummary(StepResult):
scanned: int = 0
unchanged_files: int = 0
@@ -58,6 +62,17 @@ class ReprojectionSummary:
missing_flags_cleared: int = 0
verify_flags_set: int = 0
@property
def complete(self) -> bool:
"""A path under no known root today may be under one tomorrow.
Its file is on disk -- an absent file is counted separately -- so the
root was configured away rather than deleted, and nothing else will ever
re-derive the row: the scan only builds specs for paths it has not seen.
Staying unstamped is what lets restoring the root repair them.
"""
return self.unclassified_paths == 0
def __str__(self) -> str:
return (
f"scanned={self.scanned} unchanged={self.unchanged_files} "

View File

@@ -5,6 +5,15 @@ from typing import Callable
InterruptCheck = Callable[[], bool]
@dataclass
class StepResult:
"""What a step did. A falsy ``complete`` withholds the stamp, so the step runs again."""
@property
def complete(self) -> bool:
return True
class SemanticsStepInterrupted(Exception):
"""A step stopped early on request.
@@ -19,4 +28,4 @@ class SemanticsStep:
version: int
description: str
apply: Callable[[InterruptCheck | None], object]
apply: Callable[[InterruptCheck | None], StepResult]