Rebuild stale asset records when the assets system is enabled

Alembic migrates the shape of the assets tables. Nothing migrated their
meaning, so a row could be structurally current and still hold values a
superseded rule computed, with no maintenance path that ever repaired it.

loader_path is the clearest case: the column was added to existing databases
with no backfill, and it is only ever written when a reference is first
created. The scan computes it for paths it has not seen before, so every
reference older than the column serves a null loader path forever -- while the
API tells clients to prefer loader_path over name.

A semantics version now records which generation of the derivation logic
produced a database's rows, tracked separately from the Alembic schema version
because the two move independently. Reset steps are numbered and applied in
order from the stored version, each stamped only once it finishes, so an
interrupted run resumes instead of half-applying.

The first step re-derives what a file's location implies -- loader_path, the
backend tags a path carries, and whether the file is there -- and leaves
everything else alone. It reads no file contents: verify_file_unchanged says
whether a row's recorded hash and size still describe the file, and a file that
has moved on is handed to the existing needs_verify path rather than re-read,
so an untouched model library costs one stat per file and no hashing. Manual
tags, user metadata, previews, deletions and job ids are never touched, nor are
references whose file is gone or whose path falls outside every root this
install currently knows about.

It runs at the start of a scan, before anything reads or extends those rows. A
database already at the current version costs one indexed row read.
This commit is contained in:
Simon Pinfold
2026-08-17 23:32:51 -07:00
parent cc0fc21fea
commit e5aa56ef8b
11 changed files with 1396 additions and 0 deletions

View File

@@ -23,6 +23,28 @@ from app.assets.helpers import get_utc_now
from app.database.models import Base
class AssetSemanticsVersion(Base):
"""Which generation of the derivation logic produced this database's rows.
Alembic tracks the *shape* of the assets tables; this tracks the *meaning*
of what is stored in them. A row can be structurally current and still hold
values a superseded rule computed, so the two versions move independently.
Reset steps in ``app.assets.semantics`` bring such rows forward and then
advance this stamp. Always a single row, keyed on id 1.
"""
__tablename__ = "asset_semantics_version"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, default=get_utc_now
)
def __repr__(self) -> str:
return f"<AssetSemanticsVersion version={self.version}>"
class Asset(Base):
__tablename__ = "assets"

View File

@@ -52,6 +52,17 @@ from app.assets.database.queries.asset_reference import (
update_reference_updated_at,
upsert_reference,
)
from app.assets.database.queries.semantics import (
AUTOMATIC_TAG_ORIGIN,
DerivedStateRow,
bulk_add_automatic_tags,
bulk_remove_automatic_tags,
bulk_set_loader_paths,
get_file_backed_references_page,
get_semantics_version,
get_tags_by_reference,
set_semantics_version,
)
from app.assets.database.queries.tags import (
AddTagsResult,
RemoveTagsResult,
@@ -71,7 +82,9 @@ from app.assets.database.queries.tags import (
__all__ = [
"AddTagsResult",
"AUTOMATIC_TAG_ORIGIN",
"CacheStateRow",
"DerivedStateRow",
"RemoveTagsResult",
"SetTagsResult",
"UnenrichedReferenceRow",
@@ -81,6 +94,9 @@ __all__ = [
"bulk_insert_assets",
"bulk_insert_references_ignore_conflicts",
"bulk_insert_tags_and_meta",
"bulk_add_automatic_tags",
"bulk_remove_automatic_tags",
"bulk_set_loader_paths",
"bulk_update_enrichment_level",
"count_active_siblings",
"create_stub_asset",
@@ -96,6 +112,7 @@ __all__ = [
"fetch_reference_asset_and_tags",
"get_asset_by_hash",
"get_existing_asset_ids",
"get_file_backed_references_page",
"get_or_create_reference",
"get_reference_by_file_path",
"get_reference_by_id",
@@ -104,6 +121,8 @@ __all__ = [
"get_reference_tags",
"get_references_by_paths_and_asset_ids",
"get_references_for_prefixes",
"get_semantics_version",
"get_tags_by_reference",
"get_unenriched_references",
"get_unreferenced_unhashed_asset_ids",
"insert_reference",
@@ -123,6 +142,7 @@ __all__ = [
"set_reference_metadata",
"set_reference_preview",
"set_reference_system_metadata",
"set_semantics_version",
"soft_delete_reference_by_id",
"set_reference_tags",
"update_asset_hash_and_mime",

View File

@@ -0,0 +1,195 @@
"""Queries backing the asset semantics reset (see ``app.assets.semantics``)."""
from dataclasses import dataclass
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
from sqlalchemy.orm import Session
from app.assets.database.models import (
Asset,
AssetReference,
AssetReferenceTag,
AssetSemanticsVersion,
)
from app.assets.database.queries.common import (
MAX_BIND_PARAMS,
iter_chunks,
iter_row_chunks,
)
from app.assets.database.queries.tags import ensure_tags_exist
from app.assets.helpers import get_utc_now
# The version table holds exactly one row; the id is a constant, not a sequence.
_VERSION_ROW_ID = 1
# Tags this subsystem derives from the filesystem rather than being told.
AUTOMATIC_TAG_ORIGIN = "automatic"
def get_semantics_version(session: Session) -> int:
"""Return the stored semantics version, or 0 if none has been stamped."""
row = session.get(AssetSemanticsVersion, _VERSION_ROW_ID)
return int(row.version) if row is not None else 0
def set_semantics_version(session: Session, version: int) -> None:
"""Stamp the semantics version. Caller commits."""
row = session.get(AssetSemanticsVersion, _VERSION_ROW_ID)
if row is None:
session.add(
AssetSemanticsVersion(
id=_VERSION_ROW_ID, version=int(version), updated_at=get_utc_now()
)
)
else:
row.version = int(version)
row.updated_at = get_utc_now()
session.flush()
@dataclass(frozen=True)
class DerivedStateRow:
"""A file-backed reference and the state a reprojection may rewrite."""
reference_id: str
file_path: str
loader_path: str | None
mtime_ns: int | None
is_missing: bool
needs_verify: bool
size_bytes: int | None
def get_file_backed_references_page(
session: Session,
after_id: str | None,
limit: int,
) -> list[DerivedStateRow]:
"""Return up to ``limit`` file-backed references ordered by id, after ``after_id``.
Keyset pagination on the primary key so a reprojection can walk the whole
table in bounded transactions without holding one open across the walk.
Soft-deleted references are included: their derived columns are as stale as
anyone else's, and an undelete would serve them.
"""
query = (
sa.select(
AssetReference.id,
AssetReference.file_path,
AssetReference.loader_path,
AssetReference.mtime_ns,
AssetReference.is_missing,
AssetReference.needs_verify,
Asset.size_bytes,
)
.join(Asset, Asset.id == AssetReference.asset_id)
.where(AssetReference.file_path.isnot(None))
.order_by(AssetReference.id.asc())
.limit(limit)
)
if after_id is not None:
query = query.where(AssetReference.id > after_id)
return [
DerivedStateRow(
reference_id=row[0],
file_path=row[1],
loader_path=row[2],
mtime_ns=row[3],
is_missing=bool(row[4]),
needs_verify=bool(row[5]),
size_bytes=int(row[6]) if row[6] is not None else None,
)
for row in session.execute(query).all()
]
def get_tags_by_reference(
session: Session, reference_ids: list[str]
) -> dict[str, dict[str, str]]:
"""Return {reference_id: {tag_name: origin}} for the given references."""
if not reference_ids:
return {}
by_reference: dict[str, dict[str, str]] = {}
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
rows = session.execute(
sa.select(
AssetReferenceTag.asset_reference_id,
AssetReferenceTag.tag_name,
AssetReferenceTag.origin,
).where(AssetReferenceTag.asset_reference_id.in_(chunk))
).all()
for reference_id, tag_name, origin in rows:
by_reference.setdefault(reference_id, {})[tag_name] = origin
return by_reference
def bulk_set_loader_paths(
session: Session, loader_paths: dict[str, str | None]
) -> None:
"""Set loader_path on the given references. Caller commits."""
if not loader_paths:
return
session.execute(
sa.update(AssetReference),
[
{"id": reference_id, "loader_path": loader_path}
for reference_id, loader_path in loader_paths.items()
],
)
def bulk_add_automatic_tags(session: Session, links: list[tuple[str, str]]) -> None:
"""Attach (reference_id, tag_name) links with automatic origin. Caller commits."""
if not links:
return
now = get_utc_now()
ensure_tags_exist(session, {tag_name for _, tag_name in links})
rows = [
{
"asset_reference_id": reference_id,
"tag_name": tag_name,
"origin": AUTOMATIC_TAG_ORIGIN,
"added_at": now,
}
for reference_id, tag_name in links
]
for chunk in iter_row_chunks(rows, cols_per_row=4):
session.execute(
sqlite.insert(AssetReferenceTag)
.values(chunk)
.on_conflict_do_nothing(
index_elements=[
AssetReferenceTag.asset_reference_id,
AssetReferenceTag.tag_name,
]
)
)
def bulk_remove_automatic_tags(session: Session, links: list[tuple[str, str]]) -> None:
"""Detach (reference_id, tag_name) links, but only automatic ones.
The origin is re-asserted in the statement rather than trusted from the
caller's snapshot, so a manual tag can never be removed by this path.
Caller commits.
"""
if not links:
return
by_reference: dict[str, list[str]] = {}
for reference_id, tag_name in links:
by_reference.setdefault(reference_id, []).append(tag_name)
for reference_id, tag_names in by_reference.items():
for chunk in iter_chunks(tag_names, MAX_BIND_PARAMS):
session.execute(
sa.delete(AssetReferenceTag).where(
AssetReferenceTag.asset_reference_id == reference_id,
AssetReferenceTag.tag_name.in_(chunk),
AssetReferenceTag.origin == AUTOMATIC_TAG_ORIGIN,
)
)

View File

@@ -23,6 +23,7 @@ from app.assets.scanner import (
sync_root_safely,
sync_temp_references_safely,
)
from app.assets.semantics import run_pending_semantics_steps
from app.database.db import dependencies_available
@@ -548,6 +549,11 @@ class _AssetSeeder:
)
return
# Stale rows are brought forward before anything reads or extends
# them. A database already at the current semantics version costs a
# single row read here.
run_pending_semantics_steps(interrupt_check=self._is_cancelled)
if self._prune_first:
all_prefixes = get_owned_prefixes()
marked = mark_missing_outside_prefixes_safely(all_prefixes)

View File

@@ -0,0 +1,119 @@
"""Reset steps that bring stale asset rows forward to the current semantics.
Alembic migrates the *shape* of the assets tables. Nothing migrates their
*meaning*: a row whose columns are structurally current can still hold values
that a superseded rule computed, and the steady-state scan will not repair one,
because it only ever looks at paths it has not seen before.
A reset step closes that gap. Each step is numbered, applied in order from the
version stamped in the database up to ``CURRENT_SEMANTICS_VERSION``, and stamped
only once it finishes, so an interrupted run resumes rather than half-applying.
Steps must be idempotent: re-running one on rows it has already fixed changes
nothing.
The registry, not any individual step, is the durable part. A step is free to be
as broad or as surgical as its particular drift calls for.
"""
import logging
import time
from app.assets.database.queries.semantics import (
get_semantics_version,
set_semantics_version,
)
from app.assets.semantics.reproject_derived import reproject_derived_state
from app.assets.semantics.step import (
InterruptCheck,
SemanticsStep,
SemanticsStepInterrupted,
)
from app.database.db import can_create_session, create_session
__all__ = [
"CURRENT_SEMANTICS_VERSION",
"SEMANTICS_STEPS",
"InterruptCheck",
"SemanticsStep",
"SemanticsStepInterrupted",
"run_pending_semantics_steps",
]
SEMANTICS_STEPS: tuple[SemanticsStep, ...] = (
SemanticsStep(
version=1,
description="reproject path-derived reference state",
apply=reproject_derived_state,
),
)
CURRENT_SEMANTICS_VERSION = SEMANTICS_STEPS[-1].version
def run_pending_semantics_steps(interrupt_check: InterruptCheck | None = None) -> int:
"""Apply every reset step this database has not been stamped for.
A database already at ``CURRENT_SEMANTICS_VERSION`` costs one indexed row
read and nothing else -- no filesystem is touched to discover there is
nothing to do, which is the overwhelmingly common case.
Returns the number of steps applied.
"""
if not can_create_session():
return 0
try:
with create_session() as session:
stored_version = get_semantics_version(session)
except Exception:
logging.exception(
"Could not read the asset semantics version; skipping semantics reset"
)
return 0
pending = [step for step in SEMANTICS_STEPS if step.version > stored_version]
if not pending:
return 0
logging.info(
"Asset semantics at version %d, current is %d: applying %d step(s)",
stored_version,
CURRENT_SEMANTICS_VERSION,
len(pending),
)
applied = 0
for step in pending:
started = time.perf_counter()
try:
summary = step.apply(interrupt_check)
except SemanticsStepInterrupted:
logging.info(
"Asset semantics step %d (%s) interrupted; resuming on next start",
step.version,
step.description,
)
return applied
except Exception:
logging.exception(
"Asset semantics step %d (%s) failed; database stays at version %d",
step.version,
step.description,
stored_version + applied,
)
return applied
with create_session() as session:
set_semantics_version(session, step.version)
session.commit()
applied += 1
logging.info(
"Asset semantics step %d (%s) applied in %.3fs: %s",
step.version,
step.description,
time.perf_counter() - started,
summary,
)
return applied

View File

@@ -0,0 +1,215 @@
"""Semantics step 1: re-derive the reference state that comes from the path.
Three columns are pure functions of a file's location and whether it is there:
``loader_path``, the backend tags a path implies, and ``is_missing``. Every one
of them was computed once, when the reference was first written, by whatever
rules were current that day. ``loader_path`` in particular was added to existing
databases as a NULL column and no path has ever filled it in, so references
older than that column serve a null loader path forever.
This step recomputes those three from the filesystem as it stands now. It reads
no file contents: ``verify_file_unchanged`` answers whether a file still matches
what the database recorded, and the only thing that answer is used for is
deciding *not* to touch content-derived state. Nothing here re-hashes, so a
library of untouched 500GB models costs one ``stat`` each.
What the step will not touch:
- ``hash`` and ``size_bytes`` -- content facts, unrecoverable without reading
the file. A file that has changed underneath its row is handed to the existing
``needs_verify`` path instead, exactly as the scanner would flag it.
- Anything a person chose: manual and upload-origin tags, ``user_metadata``,
``preview_id``, ``deleted_at``, ``job_id``, ``name``.
- References whose file is gone, or whose path is not under any root this
install currently knows about. Retiring the former is the scanner's job under
its own rules; the latter cannot be classified at all, and a misconfigured
``extra_model_paths.yaml`` must not be able to strip tags off assets that are
merely out of view.
"""
import logging
import os
from dataclasses import dataclass
from app.assets.database.queries import (
bulk_update_is_missing,
bulk_update_needs_verify,
)
from app.assets.database.queries.semantics import (
AUTOMATIC_TAG_ORIGIN,
DerivedStateRow,
bulk_add_automatic_tags,
bulk_remove_automatic_tags,
bulk_set_loader_paths,
get_file_backed_references_page,
get_tags_by_reference,
)
from app.assets.helpers import normalize_tags
from app.assets.semantics.step import InterruptCheck, SemanticsStepInterrupted
from app.assets.services.file_utils import verify_file_unchanged
from app.assets.services.path_utils import (
compute_loader_path,
get_path_derived_tag_vocabulary,
get_path_derived_tags_from_path,
)
from app.database.db import create_session
# Bounds each transaction. Small enough that an interrupt loses little work,
# large enough that the walk is not dominated by session setup.
_BATCH_SIZE = 500
@dataclass
class ReprojectionSummary:
"""What a reprojection pass did, for the log line."""
scanned: int = 0
unchanged_files: int = 0
changed_files: int = 0
absent_files: int = 0
unclassified_paths: int = 0
loader_paths_rewritten: int = 0
tags_added: int = 0
tags_removed: int = 0
missing_flags_cleared: int = 0
verify_flags_set: int = 0
def __str__(self) -> str:
return (
f"scanned={self.scanned} unchanged={self.unchanged_files} "
f"changed={self.changed_files} absent={self.absent_files} "
f"unclassified={self.unclassified_paths} "
f"loader_paths={self.loader_paths_rewritten} "
f"tags+{self.tags_added}/-{self.tags_removed} "
f"unflagged_missing={self.missing_flags_cleared} "
f"flagged_verify={self.verify_flags_set}"
)
def reproject_derived_state(
interrupt_check: InterruptCheck | None = None,
) -> ReprojectionSummary:
"""Re-derive path-derived state for every file-backed reference.
Walks the reference table in keyset-paginated batches, committing each one,
so a kill mid-walk leaves committed batches reprojected and the rest
untouched -- both states the step handles on its next run.
"""
summary = ReprojectionSummary()
vocabulary = get_path_derived_tag_vocabulary()
after_id: str | None = None
while True:
if interrupt_check is not None and interrupt_check():
raise SemanticsStepInterrupted(
f"interrupted after {summary.scanned} references"
)
with create_session() as session:
rows = get_file_backed_references_page(
session, after_id=after_id, limit=_BATCH_SIZE
)
if not rows:
return summary
after_id = rows[-1].reference_id
_reproject_batch(session, rows, vocabulary, summary)
session.commit()
def _reproject_batch(
session,
rows: list[DerivedStateRow],
vocabulary: set[str],
summary: ReprojectionSummary,
) -> None:
stored_tags = get_tags_by_reference(session, [row.reference_id for row in rows])
loader_paths: dict[str, str | None] = {}
tags_to_add: list[tuple[str, str]] = []
tags_to_remove: list[tuple[str, str]] = []
clear_missing: list[str] = []
set_needs_verify: list[str] = []
for row in rows:
summary.scanned += 1
present, unchanged = _classify_file(row)
if not present:
# The scanner owns retiring these, under its own missing semantics.
summary.absent_files += 1
continue
if unchanged:
summary.unchanged_files += 1
else:
# The file moved on from what the row records, so its hash, size and
# mime no longer describe it. Re-deriving those means reading the
# file, which this step does not do; flag it for the path that does.
summary.changed_files += 1
if not row.needs_verify:
set_needs_verify.append(row.reference_id)
try:
derived_tags = set(
normalize_tags(get_path_derived_tags_from_path(row.file_path))
)
except ValueError:
summary.unclassified_paths += 1
continue
loader_path = compute_loader_path(row.file_path)
if loader_path != row.loader_path:
loader_paths[row.reference_id] = loader_path
summary.loader_paths_rewritten += 1
current_tags = stored_tags.get(row.reference_id, {})
for tag_name in sorted(derived_tags - set(current_tags)):
tags_to_add.append((row.reference_id, tag_name))
for tag_name, origin in sorted(current_tags.items()):
if (
origin == AUTOMATIC_TAG_ORIGIN
and tag_name in vocabulary
and tag_name not in derived_tags
):
tags_to_remove.append((row.reference_id, tag_name))
if row.is_missing:
# The file is right there. Nothing else un-flags this: the scanner
# excludes already-missing references from its temp reconciliation.
clear_missing.append(row.reference_id)
bulk_set_loader_paths(session, loader_paths)
bulk_add_automatic_tags(session, tags_to_add)
bulk_remove_automatic_tags(session, tags_to_remove)
bulk_update_is_missing(session, clear_missing, value=False)
bulk_update_needs_verify(session, set_needs_verify, value=True)
summary.tags_added += len(tags_to_add)
summary.tags_removed += len(tags_to_remove)
summary.missing_flags_cleared += len(clear_missing)
summary.verify_flags_set += len(set_needs_verify)
def _classify_file(row: DerivedStateRow) -> tuple[bool, bool]:
"""Return (file is present, file still matches what the row recorded).
Mirrors the scanner's stat handling so the two agree about what counts as a
present file: a permission error means the file is there but unreadable, any
other OS error means treat it as gone.
"""
try:
stat_result = os.stat(row.file_path, follow_symlinks=True)
except FileNotFoundError:
return False, False
except PermissionError:
logging.debug("Permission denied accessing %s", row.file_path)
return True, False
except OSError as error:
logging.debug("OSError checking %s: %s", row.file_path, error)
return False, False
return True, verify_file_unchanged(
mtime_db=row.mtime_ns,
size_db=row.size_bytes,
stat_result=stat_result,
)

View File

@@ -0,0 +1,28 @@
"""What a semantics reset step is. See ``app.assets.semantics`` for the why."""
from dataclasses import dataclass
from typing import Callable
InterruptCheck = Callable[[], bool]
class SemanticsStepInterrupted(Exception):
"""A step stopped early on request.
Its partial work stands -- steps are idempotent, so a resumed run repeats it
harmlessly -- but the version is not stamped, so the step runs again.
"""
@dataclass(frozen=True)
class SemanticsStep:
"""One numbered reset step.
``apply`` takes an optional interrupt check and returns a summary of what it
did, which is logged. It must be idempotent and must raise
``SemanticsStepInterrupted`` rather than return if it stops early.
"""
version: int
description: str
apply: Callable[[InterruptCheck | None], object]

View File

@@ -325,6 +325,21 @@ def get_path_derived_tags_from_path(path: str) -> list[str]:
return tags
def get_path_derived_tag_vocabulary() -> set[str]:
"""Every tag get_path_derived_tags_from_path can emit under the current config.
Bounds which tags a re-derivation may take away: a stored automatic tag
inside this vocabulary that the current rules no longer emit was produced
by a superseded rule, while one outside it came from somewhere else and is
none of the derivation's business.
"""
vocabulary = {"input", "output", "temp", "models"}
vocabulary.update(_KNOWN_SUBFOLDER_TAGS)
for folder_name, _bases, _extensions in get_comfy_models_folders():
vocabulary.add(f"model_type:{folder_name}")
return vocabulary
def get_name_and_tags_from_asset_path(file_path: str) -> tuple[str, list[str]]:
"""Return (name, tags) derived from a filesystem path.