Files
UAV-CO2/src/gasflux/janitor.py
DXC c6b9783a7a fix(janitor): 禁止启动时因目录不存在而自动删除数据库任务记录
reconcile_tasks_on_startup() 中有一段逻辑:遍历所有任务,如果 output_dir
在磁盘上不存在,直接 DELETE FROM tasks。这导致重启时如果目录被移动/重命名/
被之前的 janitor 清理,所有任务记录静默丢失。

修改为仅输出 WARNING 日志,保留数据库记录。用户可通过 DELETE /task/<id> 手动清理。
2026-07-13 10:40:31 +08:00

365 lines
16 KiB
Python

"""
Janitor Module
Handles background cleanup of expired tasks and their output directories.
"""
import threading
import time
import shutil
from pathlib import Path
import sqlite3
from flask import current_app
def start_janitor(app):
"""Start the background janitor thread for cleaning up expired tasks."""
def worker():
with app.app_context():
while True:
try:
cleanup_expired_tasks()
except Exception as e:
app.logger.error(f"Janitor cleanup error: {str(e)}", exc_info=True)
finally:
# Sleep for configured interval before next cleanup cycle
cleanup_interval = int(current_app.config.get('TASK_CLEANUP_INTERVAL', 30))
time.sleep(cleanup_interval)
# Create daemon thread so it doesn't prevent app shutdown
thread = threading.Thread(target=worker, daemon=True, name="janitor")
thread.start()
app.logger.info("Janitor thread started for background cleanup")
def cleanup_expired_tasks():
"""Clean up tasks that have exceeded their deletion time."""
db_path = _get_db_path()
dry_run_config = current_app.config.get('JANITOR_DRY_RUN', 'false')
if isinstance(dry_run_config, str):
dry_run = dry_run_config.lower() == 'true'
else:
dry_run = bool(dry_run_config)
# Get cleanup intervals from config (in seconds)
failed_task_cleanup_age = current_app.config.get('FAILED_TASK_CLEANUP_AGE', 86400) # Default 24 hours for failed tasks
current_app.logger.info(f"Janitor: FAILED_TASK_CLEANUP_AGE = {failed_task_cleanup_age} seconds")
try:
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
# Log current time for debugging (using same timezone as setting)
current_time = conn.execute("SELECT datetime('now', '+8 hours')").fetchone()[0]
current_app.logger.info(f"Janitor cleanup check at: {current_time}")
# Bases used by both sections
upload_base = Path(current_app.config.get('UPLOAD_FOLDER') or '')
output_base = Path(current_app.config.get('OUTPUT_FOLDER') or '')
# Debug: Check for tasks with delete_after_at set
debug_rows = conn.execute("""
SELECT task_id, delete_after_at, downloaded_at
FROM tasks
WHERE delete_after_at IS NOT NULL
AND deleted_at IS NULL
""").fetchall()
if debug_rows:
current_app.logger.info(f"Found {len(debug_rows)} tasks with delete_after_at set:")
for row in debug_rows:
current_app.logger.info(f" Task {row['task_id']}: delete_at={row['delete_after_at']}, downloaded_at={row['downloaded_at']}")
# 1) Scheduled deletions (delete_after_at)
rows = conn.execute("""
SELECT task_id, output_dir
FROM tasks
WHERE delete_after_at IS NOT NULL
AND deleted_at IS NULL
AND delete_after_at <= datetime('now', '+8 hours')
""").fetchall()
if rows:
current_app.logger.info(f"Janitor found {len(rows)} expired tasks to clean up")
for row in rows:
task_id = row['task_id']
output_dir = row['output_dir']
try:
delete_targets = []
# 记录在库中的 output_dir
if output_dir:
p = Path(output_dir)
delete_targets.append(p)
# 兜底:按约定 outputs/<task_id>
derived_output_dir = output_base / task_id if output_base else None
if derived_output_dir and derived_output_dir not in delete_targets:
delete_targets.append(derived_output_dir)
# 同时删除 uploads/<task_id>
derived_upload_dir = upload_base / task_id if upload_base else None
if derived_upload_dir:
delete_targets.append(derived_upload_dir)
if dry_run:
for tgt in delete_targets:
if tgt:
current_app.logger.info(f"[DRY RUN] Would delete task {task_id} path: {tgt}")
else:
# 实际删除
for tgt in delete_targets:
try:
if tgt and tgt.exists():
shutil.rmtree(tgt, ignore_errors=True)
current_app.logger.info(f"Deleted path for task {task_id}: {tgt}")
else:
current_app.logger.warning(f"Path not found for task {task_id}: {tgt}")
except Exception as e:
current_app.logger.error(f"Failed to delete path {tgt} for task {task_id}: {e}")
# Hard delete from database
conn.execute(
"DELETE FROM tasks WHERE task_id = ?",
(task_id,)
)
conn.commit()
current_app.logger.info(f"Hard deleted task {task_id} from database")
except Exception as e:
current_app.logger.error(f"Failed to delete task {task_id}: {str(e)}", exc_info=True)
# Continue with other tasks even if one fails
# Debug: Check all failed tasks
debug_failed = conn.execute("""
SELECT task_id, status, created_at, started_at, finished_at, deleted_at
FROM tasks
WHERE status = 'failed' AND deleted_at IS NULL
""").fetchall()
if debug_failed:
current_app.logger.info(f"Janitor found {len(debug_failed)} total failed tasks:")
for row in debug_failed:
current_app.logger.info(f" Task {row['task_id']}: created={row['created_at']}, started={row['started_at']}, finished={row['finished_at']}")
# Clean up failed tasks that are older than configured age
failed_rows = conn.execute("""
SELECT task_id, output_dir
FROM tasks
WHERE status = 'failed'
AND deleted_at IS NULL
AND COALESCE(finished_at, started_at, created_at) <= datetime('now', '+8 hours', '-' || ? || ' seconds')
""", (failed_task_cleanup_age,)).fetchall()
if failed_rows:
current_app.logger.info(f"Janitor found {len(failed_rows)} failed tasks older than {failed_task_cleanup_age} seconds")
for row in failed_rows:
current_app.logger.info(f" Failed task: {row['task_id']}")
for row in failed_rows:
task_id = row['task_id']
output_dir = row['output_dir']
try:
delete_targets = []
# 记录在库中的 output_dir
if output_dir:
p = Path(output_dir)
delete_targets.append(p)
# 兜底:按约定 outputs/<task_id>
derived_output_dir = output_base / task_id if output_base else None
if derived_output_dir and derived_output_dir not in delete_targets:
delete_targets.append(derived_output_dir)
# 同时删除 uploads/<task_id>
derived_upload_dir = upload_base / task_id if upload_base else None
if derived_upload_dir:
delete_targets.append(derived_upload_dir)
if dry_run:
for tgt in delete_targets:
if tgt:
current_app.logger.info(f"[DRY RUN] Would delete failed task {task_id} path: {tgt}")
else:
# 实际删除
for tgt in delete_targets:
try:
if tgt and tgt.exists():
shutil.rmtree(tgt, ignore_errors=True)
current_app.logger.info(f"Deleted failed task {task_id} path: {tgt}")
else:
current_app.logger.warning(f"Path not found for failed task {task_id}: {tgt}")
except Exception as e:
current_app.logger.error(f"Failed to delete path {tgt} for failed task {task_id}: {e}")
# Hard delete from database
conn.execute(
"DELETE FROM tasks WHERE task_id = ?",
(task_id,)
)
conn.commit()
current_app.logger.info(f"Auto-deleted failed task {task_id} from database (older than {failed_task_cleanup_age} seconds)")
except Exception as e:
current_app.logger.error(f"Failed to delete failed task {task_id}: {str(e)}", exc_info=True)
# Continue with other tasks even if one fails
except Exception as e:
current_app.logger.error(f"Database error during cleanup: {str(e)}", exc_info=True)
finally:
if 'conn' in locals():
conn.close()
def reconcile_tasks_on_startup():
"""Reconcile task states on application startup."""
db_path = _get_db_path()
# Get cleanup ages from config (in seconds)
successful_task_cleanup_age = current_app.config.get('SUCCESSFUL_TASK_CLEANUP_AGE', 3600)
failed_task_cleanup_age = current_app.config.get('FAILED_TASK_CLEANUP_AGE', 86400)
try:
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
# Fix tasks that were downloaded but don't have delete_after_at set
# This handles cases where the app crashed after marking downloaded but before setting delete time
conn.execute("""
UPDATE tasks
SET delete_after_at = COALESCE(delete_after_at, datetime(downloaded_at, '+' || ? || ' seconds'))
WHERE downloaded_at IS NOT NULL
AND deleted_at IS NULL
AND delete_after_at IS NULL
""", (successful_task_cleanup_age,))
updated_count = conn.total_changes
if updated_count > 0:
current_app.logger.info(f"Startup reconciliation: Fixed delete_after_at for {updated_count} downloaded tasks")
# Clean up failed tasks that are older than configured age
failed_rows = conn.execute("""
SELECT task_id, output_dir
FROM tasks
WHERE status = 'failed'
AND deleted_at IS NULL
AND COALESCE(finished_at, started_at, created_at) <= datetime('now', '+8 hours', '-' || ? || ' seconds')
""", (failed_task_cleanup_age,)).fetchall()
if failed_rows:
current_app.logger.info(f"Startup reconciliation: Found {len(failed_rows)} failed tasks older than {failed_task_cleanup_age} seconds")
upload_base = Path(current_app.config.get('UPLOAD_FOLDER') or '')
output_base = Path(current_app.config.get('OUTPUT_FOLDER') or '')
dry_run_config = current_app.config.get('JANITOR_DRY_RUN', 'false')
if isinstance(dry_run_config, str):
dry_run = dry_run_config.lower() == 'true'
else:
dry_run = bool(dry_run_config)
for row in failed_rows:
task_id = row['task_id']
output_dir = row['output_dir']
try:
delete_targets = []
# 记录在库中的 output_dir
if output_dir:
p = Path(output_dir)
delete_targets.append(p)
# 兜底:按约定 outputs/<task_id>
derived_output_dir = output_base / task_id if output_base else None
if derived_output_dir and derived_output_dir not in delete_targets:
delete_targets.append(derived_output_dir)
# 同时删除 uploads/<task_id>
derived_upload_dir = upload_base / task_id if upload_base else None
if derived_upload_dir:
delete_targets.append(derived_upload_dir)
if dry_run:
for tgt in delete_targets:
if tgt:
current_app.logger.info(f"[DRY RUN] Would delete failed task {task_id} path: {tgt}")
else:
# 实际删除
for tgt in delete_targets:
try:
if tgt and tgt.exists():
import shutil
shutil.rmtree(tgt, ignore_errors=True)
current_app.logger.info(f"Deleted failed task {task_id} path: {tgt}")
else:
current_app.logger.warning(f"Path not found for failed task {task_id}: {tgt}")
except Exception as e:
current_app.logger.error(f"Failed to delete path {tgt} for failed task {task_id}: {e}")
# Hard delete from database
conn.execute(
"DELETE FROM tasks WHERE task_id = ?",
(task_id,)
)
current_app.logger.info(f"Startup reconciliation: Auto-deleted failed task {task_id} from database (older than {failed_task_cleanup_age} seconds)")
except Exception as e:
current_app.logger.error(f"Failed to delete failed task {task_id}: {str(e)}", exc_info=True)
# 检查 output_dir 不存在的任务(仅警告,不自动删除)
rows = conn.execute("""
SELECT task_id, output_dir
FROM tasks
WHERE output_dir IS NOT NULL
AND deleted_at IS NULL
""").fetchall()
orphaned_count = 0
for row in rows:
task_id_from_db = row[0]
output_dir_from_db = row[1]
if not Path(output_dir_from_db).exists():
current_app.logger.warning(
f"Startup reconciliation: Task {task_id_from_db} output directory "
f"not found on disk: {output_dir_from_db}. Database record preserved."
)
orphaned_count += 1
if orphaned_count > 0:
current_app.logger.info(
f"Startup reconciliation: Found {orphaned_count} tasks with missing directories "
f"(records preserved, use DELETE /task/<id> to manually clean up)"
)
# Backfill output_dir for rows with NULL, using OUTPUT_FOLDER/<task_id>
output_base = Path(current_app.config.get('OUTPUT_FOLDER') or '')
if output_base:
rows2 = conn.execute("""
SELECT task_id FROM tasks
WHERE (output_dir IS NULL OR output_dir = '')
AND deleted_at IS NULL
""").fetchall()
for r in rows2:
task_id_from_db = r[0] # task_id是第一个字段
guess = output_base / task_id_from_db
conn.execute("UPDATE tasks SET output_dir = ? WHERE task_id = ?", (str(guess), task_id_from_db))
current_app.logger.info(f"Backfilled output_dir for task {task_id_from_db}: {guess}")
conn.commit()
except Exception as e:
current_app.logger.error(f"Startup reconciliation error: {str(e)}", exc_info=True)
finally:
if 'conn' in locals():
conn.close()
def _get_db_path():
"""Get database file path from app config."""
from .db import get_db_path as get_config_db_path
return get_config_db_path(current_app)