Add variant-group data model for cross-model queue alternatives (#671)

Adds file_variant_groups plus variant_group_id / variant_position on
library_files, so a set of files that are the same job sliced for
different printers can be resolved to whichever printer frees up first.

Backfills groups from the sliced_from_library_file_id provenance that
slice_and_persist and the pipeline runner have been writing into
file_metadata since they shipped, and which nothing has ever read.
Only sources with two or more children carrying distinct
sliced_for_model values are grouped: a single candidate is not a
choice, and two slices for the same printer give the resolver no basis
to prefer one.
This commit is contained in:
maziggy
2026-08-03 10:17:28 +02:00
parent b2f84cd37f
commit da07c5884b
6 changed files with 9224 additions and 1 deletions
+110
View File
@@ -3949,6 +3949,116 @@ async def run_migrations(conn):
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
)
# Migration: variant grouping for library files (#671 / #2570). The
# `file_variant_groups` table itself needs no migration — create_all() above
# builds it — but the two member-side columns do. INTEGER and the inline
# REFERENCES clause are spelled identically on SQLite and Postgres, and
# SQLite accepts a REFERENCES on ADD COLUMN (same form as the
# pipeline_runs.parent_run_id migration at the top of this function).
await _safe_execute(
conn,
"ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
"REFERENCES file_variant_groups(id) ON DELETE SET NULL",
)
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
# The model declares index=True, so fresh installs get this from create_all();
# migrated databases need it spelled out. Resolution looks members up by group
# on every scheduler pass that touches a grouped item.
await _safe_execute(
conn,
"CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
)
await _migrate_backfill_variant_groups(conn)
async def _migrate_backfill_variant_groups(conn) -> None:
"""Build variant groups from the slice provenance already on disk (#671 / #2570).
``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
since those features shipped, and until now nothing ever read it back — the
link existed but was inert. This promotes it to real group membership so an
existing library arrives with its slice sets already grouped instead of
requiring the user to re-declare by hand what Bambuddy itself recorded.
Only sources with **two or more** sliced children carrying **distinct**
``sliced_for_model`` values produce a group:
- Fewer than two candidates is not a choice, and a one-member group would
change nothing at print time while creating a row per sliced file in every
library on earth.
- Two children sliced for the same printer are not alternatives — the
resolver has no basis to prefer one, so grouping them would turn a
harmless duplicate into an arbitrary pick. Those sources are skipped
whole; the user can still group them by hand and choose an order.
The unsliced source file is deliberately not a member. It has no
``sliced_for_model``, so it can never be a dispatch candidate; showing it
alongside its variants is a File Manager listing concern, which is out of
scope.
Idempotent: only files with no group yet are considered, so a re-run after a
partial apply resumes rather than duplicating, and a user who has since
ungrouped files by hand does not get them silently regrouped.
"""
from sqlalchemy import text
from backend.app.models.library import FileVariantGroup
if is_sqlite():
source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
else:
# file_metadata is JSON, not JSONB — cast before using the -> operators,
# matching _migrate_drop_library_print_name above.
source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
model_expr = "file_metadata::jsonb->>'sliced_for_model'"
async with conn.begin_nested():
rows = (
await conn.execute(
text(
f"SELECT id, {source_expr} AS source_id, {model_expr} AS model " # noqa: S608 — dialect literals
"FROM library_files "
f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
"AND variant_group_id IS NULL AND deleted_at IS NULL "
"ORDER BY id"
)
)
).fetchall()
by_source: dict[str, list[tuple[int, str]]] = {}
for file_id, source_id, model in rows:
by_source.setdefault(str(source_id), []).append((file_id, str(model)))
for source_id, members in by_source.items():
if len(members) < 2:
continue
models = [m for _, m in members]
if len(set(models)) != len(models):
# Same printer sliced twice — ambiguous, leave it to the user.
continue
# Name the group after the source file when it is still around; its
# filename is what the user recognises. A deleted source leaves the
# variants perfectly usable, so fall back rather than skip.
name_row = (
await conn.execute(
text("SELECT filename FROM library_files WHERE id = :sid"),
{"sid": int(source_id)},
)
).fetchone()
group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
group_id = result.inserted_primary_key[0]
for position, (file_id, _model) in enumerate(members):
await conn.execute(
text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
{"gid": group_id, "pos": position, "fid": file_id},
)
_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
("user_print_start", "User Print Started", "User Print Started Email"),
+2 -1
View File
@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.group import Group, user_groups
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
from backend.app.models.local_preset import LocalPreset
from backend.app.models.location import Location
from backend.app.models.long_lived_token import LongLivedToken
@@ -61,6 +61,7 @@ __all__ = [
"PrintBatch",
"LibraryFolder",
"LibraryFile",
"FileVariantGroup",
"Location",
"User",
"Group",
+47
View File
@@ -60,6 +60,41 @@ class LibraryFolder(Base):
archive: Mapped["PrintArchive | None"] = relationship()
class FileVariantGroup(Base):
"""A set of library files that are the same job sliced for different printers.
Members are peers, not a source/output hierarchy. The group answers one
question — "which of these files goes to an H2S, and which to an H2C" — and
both open features need that answer from opposite ends: the print queue
picks the printer and needs the matching file (#671), the File Manager's
print action has the printer already and needs the same match (#2570).
The group deliberately stores no model information of its own. Each
member's target model comes from its own ``file_metadata['sliced_for_model']``,
parsed out of the 3MF, so a group can never disagree with the files it
contains. It also carries no pointer to an unsliced source file: that is a
display concern for the grouped File Manager listing, which is not built.
Deleting a group ungroups its files rather than deleting them (the member
side is ON DELETE SET NULL) — every member is independently printable.
"""
__tablename__ = "file_variant_groups"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
files: Mapped[list["LibraryFile"]] = relationship(
back_populates="variant_group",
order_by="LibraryFile.variant_position",
)
created_by: Mapped["User | None"] = relationship()
class LibraryFile(Base):
"""File stored in the library."""
@@ -98,6 +133,17 @@ class LibraryFile(Base):
source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
# Variant grouping (#671 / #2570). A file belongs to at most one group of
# "same job, sliced for a different printer" siblings. SET NULL on group
# delete: ungrouping must never take the files with it. ``variant_position``
# is the user's priority order within the group — when two printers are idle
# at the same scheduler tick, the lowest position wins, so the pick is
# reproducible instead of depending on which match the scheduler found first.
variant_group_id: Mapped[int | None] = mapped_column(
ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
)
variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# User tracking (Issue #206)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
@@ -122,6 +168,7 @@ class LibraryFile(Base):
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
project: Mapped["Project | None"] = relationship()
created_by: Mapped["User | None"] = relationship()
variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
# Tags (#1268). M2M via library_file_tags. Loaded explicitly via
# ``selectinload`` in list_files so each row in the listing carries its
# chip set without N+1 fetches.
@@ -0,0 +1,302 @@
"""Tests for the variant-group backfill migration (#671 / #2570).
`sliced_from_library_file_id` has been written into `library_files.file_metadata`
by the Slice button and the pipeline runner since those features shipped, and
nothing ever read it back. The migration promotes that inert provenance into
real `file_variant_groups` membership so an existing library arrives with its
slice sets already grouped.
The interesting behaviour is all in what it refuses to group: a lone child, two
children sliced for the same printer, files the user has already grouped by
hand, and trashed rows.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import run_migrations
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
"""Force the SQLite branch regardless of test env settings."""
from backend.app.core import db_dialect
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
from backend.app.core import database as database_module
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
def _register_all_models():
from backend.app.models import ( # noqa: F401
ams_history,
ams_label,
api_key,
archive,
color_catalog,
external_link,
filament,
group,
kprofile_note,
library,
maintenance,
notification,
notification_template,
print_log,
print_queue,
printer,
project,
project_bom,
settings,
slot_preset,
smart_plug,
smart_plug_energy_snapshot,
spool,
spool_assignment,
spool_catalog,
spool_k_profile,
spool_usage_history,
spoolbuddy_device,
user,
user_email_pref,
virtual_printer,
)
@pytest.fixture
async def engine():
from backend.app.core.database import Base
_register_all_models()
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
async def _insert_file(
conn,
*,
file_id: int,
filename: str,
metadata: dict | None = None,
deleted: bool = False,
variant_group_id: int | None = None,
) -> None:
"""Insert a minimal LibraryFile row; only the columns the migration reads."""
await conn.execute(
text(
"INSERT INTO library_files "
"(id, filename, file_path, file_type, file_size, is_external, print_count, "
" file_metadata, deleted_at, variant_group_id, variant_position) "
"VALUES (:id, :filename, :path, 'gcode.3mf', 0, 0, 0, :meta, :deleted, :gid, 0)"
),
{
"id": file_id,
"filename": filename,
"path": f"/lib/{file_id}",
"meta": json.dumps(metadata) if metadata is not None else None,
"deleted": "2026-01-01 00:00:00" if deleted else None,
"gid": variant_group_id,
},
)
def _variant(source_id: int, model: str) -> dict:
return {"sliced_from_library_file_id": source_id, "sliced_for_model": model}
async def _members(conn) -> dict[int, tuple[int | None, int]]:
rows = (
await conn.execute(text("SELECT id, variant_group_id, variant_position FROM library_files ORDER BY id"))
).fetchall()
return {r[0]: (r[1], r[2]) for r in rows}
async def _group_count(conn) -> int:
return (await conn.execute(text("SELECT COUNT(*) FROM file_variant_groups"))).scalar()
@pytest.mark.asyncio
async def test_groups_two_variants_of_the_same_source(engine):
"""The whole point: an H2S slice and an H2C slice of one model become a group."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 1
members = await _members(conn)
gid = members[2][0]
assert gid is not None
assert members[3][0] == gid, "both slices land in the same group"
assert members[1][0] is None, "the unsliced source is not a dispatch candidate"
assert (members[2][1], members[3][1]) == (0, 1), "position follows id order, deterministically"
name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
assert name == "bracket.3mf", "the group is named after the source the user recognises"
@pytest.mark.asyncio
async def test_single_variant_produces_no_group(engine):
"""One candidate is not a choice — grouping it would add a row per sliced
file in every library while changing nothing at print time."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 0
assert (await _members(conn))[2][0] is None
@pytest.mark.asyncio
async def test_duplicate_model_is_skipped_whole(engine):
"""Two slices for the same printer are not alternatives — the resolver would
have no basis to prefer one, so the source is left entirely alone."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_draft.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_fine.gcode.3mf", metadata=_variant(1, "H2S"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 0
members = await _members(conn)
assert members[2][0] is None and members[3][0] is None
@pytest.mark.asyncio
async def test_variant_without_model_is_not_a_candidate(engine):
"""A child with no `sliced_for_model` can never be matched to a printer, so
it does not count towards the two-candidate threshold."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(
conn,
file_id=3,
filename="bracket_unknown.gcode.3mf",
metadata={"sliced_from_library_file_id": 1},
)
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 0
@pytest.mark.asyncio
async def test_trashed_variants_are_excluded(engine):
"""A soft-deleted file is not printable, so it must not make up the second
candidate that tips a source into being grouped."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"), deleted=True)
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 0
@pytest.mark.asyncio
async def test_missing_source_still_groups_with_fallback_name(engine):
"""Deleting the source model does not make its slices any less usable
together, so the group is still built — just named differently."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(99, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(99, "H2C"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 1
name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
assert name == "H2S + 1 more"
@pytest.mark.asyncio
async def test_separate_sources_get_separate_groups(engine):
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
await _insert_file(conn, file_id=4, filename="clip.3mf")
await _insert_file(conn, file_id=5, filename="clip_h2s.gcode.3mf", metadata=_variant(4, "H2S"))
await _insert_file(conn, file_id=6, filename="clip_h2c.gcode.3mf", metadata=_variant(4, "H2C"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 2
members = await _members(conn)
assert members[2][0] == members[3][0]
assert members[5][0] == members[6][0]
assert members[2][0] != members[5][0]
@pytest.mark.asyncio
async def test_backfill_is_idempotent(engine):
"""Every boot re-runs the migration set; the second pass must not clone the
group or renumber its members."""
async with engine.begin() as conn:
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
first = await _members(conn)
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 1
assert await _members(conn) == first
@pytest.mark.asyncio
async def test_hand_grouped_files_are_left_alone(engine):
"""A user who has already grouped (or deliberately ungrouped) files owns that
decision — the backfill only ever considers files with no group yet."""
async with engine.begin() as conn:
await conn.execute(text("INSERT INTO file_variant_groups (id, name) VALUES (7, 'my own grouping')"))
await _insert_file(conn, file_id=1, filename="bracket.3mf")
await _insert_file(
conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"), variant_group_id=7
)
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert await _group_count(conn) == 1, "no second group is invented"
members = await _members(conn)
assert members[2][0] == 7, "the user's grouping survives"
assert members[3][0] is None, "and the leftover sibling is not force-joined to it"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long