mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
The reporter's 19-printer farm started prints "one by one", up to an hour apart. check_queue awaited each dispatch inline, and a dispatch includes the FTP upload, so every printer queued behind every other printer's transfer despite being an independent machine. His logs give the arithmetic: 40978500 bytes in 254.1s, 157 KB/s - a Bambu printer's SD write, not the network, is the bottleneck. Nineteen of those in series is ~80 minutes, and the next upload started 131 ms after the previous one finished. The delay is linear in fleet size, which is why it got worse the more printers he selected. Dispatch is now collected during the (still sequential) selection loop and run concurrently afterwards, capped by queue_max_concurrent_uploads - Settings -> Workflow -> Queue & Dispatch, default 4, 1 restores the old behaviour. Every gate is untouched; only the transfers overlap. The pass still awaits its uploads before returning: _start_print flips the row pending -> printing only after the upload, so an early return would let the next tick re-dispatch the same rows. FTP work moves to its own thread pool. It was on asyncio's default executor - min(32, cpu+4), six threads on a 2-core NAS, shared with everything else - which was survivable only while uploads were serial. Two problems the same bundle exposed: A printer that accepts project_file but never starts (#1678) was retried forever: 270s watchdog, revert to pending, re-upload the whole file, repeat. Hence his "printer who, since the morning, still not launch" - and on a farm each lap also eats an upload slot the other printers are waiting on. Attempts are now counted on the queue item; after three it fails with a message pointing at the printer instead of queueing a fourth re-upload. The debug bundle we asked him for held 4m49s of history. The push_status dumps fired on every frame rather than on change - several while their own comment claimed otherwise - which is 27,727 of the bundle's 29,830 lines and rolls 5 MB in under five minutes on 19 printers. They now log transitions only. The bundle also read just the live log while three rotated backups sat next to it, under a byte budget four times larger than the file it was reading. Migration verified on SQLite and Postgres: idempotent, backfills legacy NULLs (dispatch_attempts + 1 is NULL for a NULL row, which would silently disable the cap). Tests: 6 on concurrent dispatch (overlap, cap honoured, 1 == serial, default applies with no settings row, a failed printer does not cancel its siblings, no early return), 4 on the retry budget, 6 on the bundle's rotated-log span, 7 on the debug gating. Each verified to fail against the unfixed code - the first end-to-end log assertion I wrote passed without the fix and had to be tightened.
164 lines
9.2 KiB
Python
164 lines
9.2 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
class PrintQueueItem(Base):
|
|
"""Print queue item for scheduled/queued prints."""
|
|
|
|
__tablename__ = "print_queue"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
# Links
|
|
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
|
|
# Target printer model for model-based assignment (mutually exclusive with printer_id)
|
|
# When set, scheduler assigns to any idle printer of matching model
|
|
target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
# Target location filter for model-based assignment (only used with target_model)
|
|
# When set, only printers in this location are considered
|
|
target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
# Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
|
|
# Used by scheduler to validate printer has compatible filaments loaded
|
|
required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Waiting reason - explains why a model-based job hasn't started yet
|
|
# Set by scheduler when no matching printer is available
|
|
waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Either archive_id OR library_file_id must be set (archive created at print start from library file)
|
|
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
|
|
library_file_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
|
batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
|
|
|
|
# Scheduling
|
|
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
|
|
scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = ASAP
|
|
manual_start: Mapped[bool] = mapped_column(Boolean, default=False) # Requires manual trigger to start
|
|
|
|
# Conditions
|
|
require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Power management
|
|
auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False) # Power off printer after print
|
|
|
|
# AMS mapping: JSON array of global tray IDs for each filament slot
|
|
# Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
|
|
ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Filament overrides for model-based assignment: JSON array of override objects
|
|
# Format: '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]'
|
|
# Only slots with overrides are included (sparse). null = use original 3MF values.
|
|
filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
|
|
plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
# Shortest-job-first scheduling
|
|
print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cached from archive/library
|
|
been_jumped: Mapped[bool] = mapped_column(Boolean, default=False) # Starvation guard for SJF
|
|
|
|
# Auto-print G-code injection (#422)
|
|
gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# How many times the start-watchdog has reverted this item from 'printing'
|
|
# back to 'pending' (#2555). A printer that accepts project_file but never
|
|
# starts (#1678) used to be retried forever: upload, wait out the watchdog,
|
|
# revert, upload again — burning a full 3MF transfer per cycle and, with
|
|
# the queue dispatching serially, dragging every other printer's start time
|
|
# out with it. The counter bounds that loop; see DISPATCH_MAX_ATTEMPTS.
|
|
dispatch_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
# H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
|
|
# project_file MQTT command for rack-swap-capable models (O1C2 today)
|
|
# carries per-filament physical nozzle position IDs in `nozzle_mapping`,
|
|
# forwarded verbatim through the queue and replayed by the dispatcher so
|
|
# the firmware honours the user's pick instead of falling back to
|
|
# "last matching nozzle type" auto-pick. Stored as opaque JSON string
|
|
# (list[int]); NULL on every other model. `nozzles_info` is a deprecated
|
|
# column from the original #1780 attempt — kept nullable so old rows still
|
|
# load; never written to or read from.
|
|
nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Printer-card direct uploads create transient library rows. When this is
|
|
# true, the scheduler deletes the source row/files after archiving a copy.
|
|
cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Print options
|
|
bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
# Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
|
|
nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
# Preheat / heat-soak override (#1468). 'inherit' uses the global
|
|
# preheat_enabled setting; 'on' / 'off' force the per-item decision. The
|
|
# chamber target falls through: per-item override → max(filament-map[loaded
|
|
# tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
|
|
# null = no preheat. Default 'inherit' so existing queue items behave
|
|
# exactly as before the migration.
|
|
preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
|
|
preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
# Status: pending, printing, completed, failed, skipped, cancelled
|
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
|
|
|
# Cleared by the per-printer "Resume after failure" action (#1818) so the
|
|
# scheduler's `_check_previous_success` lookback skips this row. Without
|
|
# this, a single `failed` or `aborted` print poisoned every later
|
|
# `require_previous_success` item on the same printer forever — the
|
|
# lookback excluded `skipped` but had no way to dismiss the originating
|
|
# failure. The flag is per-item, not per-printer, so a fresh failure
|
|
# after a resume re-gates downstream items independently.
|
|
gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Set by the dispatch scheduler when the assigned spool can't satisfy
|
|
# this print's per-slot filament weight (#1496). Display-only flag — the
|
|
# actual deficit is recomputed live every time the user clicks ▶, so
|
|
# swapping a spool to a fuller one between flag and dispatch clears the
|
|
# block automatically.
|
|
filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# User has acknowledged the filament-shortage warning for this item
|
|
# ("Print Anyway"). Set by the start route when the user passes
|
|
# skip_filament_check=true, or at queue-creation time if PrintModal's
|
|
# frontend deficit warning was acknowledged. Survives scheduler ticks so
|
|
# the dispatch no longer bounces between "user said anyway" and
|
|
# "scheduler re-flagged" (#1698-followup).
|
|
skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Tracking
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
|
|
# User tracking (who added this to the queue)
|
|
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
|
|
# Relationships
|
|
printer: Mapped["Printer"] = relationship()
|
|
archive: Mapped["PrintArchive | None"] = relationship()
|
|
library_file: Mapped["LibraryFile | None"] = relationship()
|
|
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
|
|
batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
|
|
created_by: Mapped["User | None"] = relationship()
|
|
|
|
|
|
from backend.app.models.archive import PrintArchive # noqa: E402
|
|
from backend.app.models.library import LibraryFile # noqa: E402
|
|
from backend.app.models.print_batch import PrintBatch # noqa: E402
|
|
from backend.app.models.printer import Printer # noqa: E402
|
|
from backend.app.models.project import Project # noqa: E402
|
|
from backend.app.models.user import User # noqa: E402
|