Fix phantom reprint bug in print scheduler

Addresses reports of files being automatically reprinted hours after
the original print completed. The root cause was a race condition where
the queue item status was updated to "printing" AFTER the print command
was sent, allowing stuck "pending" items to be re-triggered on restart.

Changes:
- Set status to "printing" BEFORE sending print command to prevent
  re-triggering if backend crashes after print starts
- Add 24-hour expiration for stale pending queue items
- Add duplicate prevention: skip archives completed within last 4 hours

Trade-off: If backend crashes after status update but before print
command, item will be stuck in "printing" without actually printing.
This is safer than accidentally reprinting and wasting filament.
This commit is contained in:
maziggy
2026-02-03 17:18:22 +01:00
parent 1120debf75
commit b6ce486fba
+48 -5
View File
@@ -5,7 +5,7 @@ import json
import logging
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import func, select
@@ -76,6 +76,18 @@ class PrintScheduler:
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
continue
# Safety: Skip stale items (older than 24 hours) to prevent phantom reprints
# This protects against items that got stuck in "pending" status due to
# crashes/restarts after the print already started
stale_threshold = timedelta(hours=24)
if item.created_at and datetime.utcnow() - item.created_at.replace(tzinfo=None) > stale_threshold:
logger.warning(f"Queue item {item.id} is stale (created {item.created_at}), marking as expired")
item.status = "expired"
item.error_message = "Queue item expired - older than 24 hours"
item.completed_at = datetime.utcnow()
await db.commit()
continue
# Skip items that require manual start
if item.manual_start:
continue
@@ -809,6 +821,28 @@ class PrintScheduler:
logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
await self._power_off_if_needed(db, item)
return
# Safety: Check if this archive was printed recently (within 4 hours)
# This prevents phantom reprints if a queue item got stuck in "pending"
# after its print already started due to a crash/restart
if archive.status == "completed" and archive.completed_at:
completed_at = (
archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
)
time_since_completed = datetime.utcnow() - completed_at
if time_since_completed < timedelta(hours=4):
logger.warning(
f"Queue item {item.id}: Archive {item.archive_id} was already printed "
f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
)
item.status = "skipped"
item.error_message = (
f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
)
item.completed_at = datetime.utcnow()
await db.commit()
return
file_path = settings.base_dir / archive.file_path
filename = archive.filename
@@ -953,6 +987,17 @@ class PrintScheduler:
except json.JSONDecodeError:
logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
# IMPORTANT: Set status to "printing" BEFORE sending the print command.
# This prevents phantom reprints if the backend crashes/restarts after the
# print command is sent but before the status update is committed.
# If we crash after this commit but before start_print(), the item will be
# in "printing" status without actually printing - but that's safer than
# accidentally reprinting the same file hours later.
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
logger.info(f"Queue item {item.id}: Status set to 'printing', sending print command...")
# Start the print with AMS mapping, plate_id and print options
started = printer_manager.start_print(
item.printer_id,
@@ -968,10 +1013,7 @@ class PrintScheduler:
)
if started:
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
logger.info(f"Queue item {item.id}: Print started - {filename}")
logger.info(f"Queue item {item.id}: Print started successfully - {filename}")
# Get estimated time for notification
estimated_time = None
@@ -1003,6 +1045,7 @@ class PrintScheduler:
except Exception:
pass # Don't fail if MQTT fails
else:
# Print command failed - revert status
item.status = "failed"
item.error_message = "Failed to send print command to printer"
item.completed_at = datetime.utcnow()