失败任务定时删除

This commit is contained in:
2026-02-11 16:28:51 +08:00
parent b9828a1b13
commit af56c94efe
13 changed files with 782 additions and 169 deletions

View File

@ -40,6 +40,8 @@ def get_config():
"GASFLUX_CORS_ORIGINS",
"GASFLUX_TASK_CLEANUP_INTERVAL",
"GASFLUX_MAX_TASK_AGE",
"GASFLUX_SUCCESSFUL_TASK_CLEANUP_AGE",
"GASFLUX_FAILED_TASK_CLEANUP_AGE",
"GASFLUX_THREADS",
"GASFLUX_CONNECTION_LIMIT",
"GASFLUX_CHANNEL_TIMEOUT"
@ -52,6 +54,7 @@ def get_config():
"GASFLUX_MAX_CONTENT_LENGTH", "GASFLUX_LOG_LEVEL",
"GASFLUX_LOG_FILE", "GASFLUX_CORS_ORIGINS",
"GASFLUX_TASK_CLEANUP_INTERVAL", "GASFLUX_MAX_TASK_AGE",
"GASFLUX_SUCCESSFUL_TASK_CLEANUP_AGE", "GASFLUX_FAILED_TASK_CLEANUP_AGE",
"GASFLUX_THREADS", "GASFLUX_CONNECTION_LIMIT",
"GASFLUX_CHANNEL_TIMEOUT"
]

View File

@ -19,21 +19,24 @@ def _mark_task_downloaded(task_id):
from ..db import get_db_path as get_config_db_path
db_path = get_config_db_path(current_app)
# Get cleanup age for successful tasks from config (in seconds)
successful_task_cleanup_age = current_app.config.get('SUCCESSFUL_TASK_CLEANUP_AGE', 3600)
try:
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=3000")
# Update downloaded timestamp and set deletion time (10 minutes later)
# Update downloaded timestamp and set deletion time based on config
conn.execute("""
UPDATE tasks
SET downloaded_at = datetime('now', '+8 hours'),
delete_after_at = datetime('now', '+8 hours', '+10 minutes')
delete_after_at = datetime('now', '+8 hours', '+' || ? || ' seconds')
WHERE task_id = ?
""", (task_id,))
""", (successful_task_cleanup_age, task_id))
conn.commit()
logger.info(f"Task {task_id} marked as downloaded, scheduled for deletion in 10 minutes")
logger.info(f"Task {task_id} marked as downloaded, scheduled for deletion in {successful_task_cleanup_age} seconds")
except Exception as e:
logger.error(f"Failed to mark task {task_id} as downloaded: {str(e)}", exc_info=True)

View File

@ -54,10 +54,11 @@ def get_stats():
current_time = time.time()
rows = db.execute("""
SELECT task_id, status, message, updated_at
SELECT task_id, status, message,
COALESCE(finished_at, started_at, created_at) as updated_at
FROM tasks
WHERE deleted_at IS NULL
ORDER BY updated_at DESC
ORDER BY COALESCE(finished_at, started_at, created_at) DESC
LIMIT 20
""").fetchall()

View File

@ -16,6 +16,141 @@ from ..app import process_data_async
from ..shared import _format_response, log_performance, logger, ALLOWED_DATA_EXTENSIONS, ALLOWED_CONFIG_EXTENSIONS, allowed_file, update_task_status, TASK_STATUS_PENDING, TASK_STATUS_FAILED
from ..auth import require_api_key
def get_file_extension(filename):
"""获取文件扩展名,与 allowed_file 函数保持一致"""
if '.' not in filename:
return ''
return '.' + filename.rsplit('.', 1)[1].lower()
def validate_upload_files(data_file, config_file=None):
"""验证上传文件并返回详细的错误信息"""
errors = []
# 检查数据文件
if not data_file or data_file.filename == '':
errors.append({
'field': 'file',
'error': '数据文件不能为空',
'details': '请上传一个有效的Excel文件'
})
elif not allowed_file(data_file.filename, ALLOWED_DATA_EXTENSIONS):
errors.append({
'field': 'file',
'error': f'不支持的文件类型: {get_file_extension(data_file.filename)}',
'details': f'只支持以下格式: {", ".join(ALLOWED_DATA_EXTENSIONS)}'
})
# 检查配置文件(如果提供)
if config_file and config_file.filename != '':
if not allowed_file(config_file.filename, ALLOWED_CONFIG_EXTENSIONS):
errors.append({
'field': 'config',
'error': f'不支持的配置文件类型: {get_file_extension(config_file.filename)}',
'details': f'只支持以下格式: {", ".join(ALLOWED_CONFIG_EXTENSIONS)}'
})
return errors
def parse_config_file(config_file):
"""解析配置文件并提供详细错误信息"""
if not config_file or config_file.filename == '':
# 使用默认配置
try:
default_config_path = Path(__file__).parent.parent / "gasflux_config.yaml"
with open(default_config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f), None
except Exception as e:
return None, f"加载默认配置文件失败: {str(e)}"
try:
config_file.stream.seek(0)
config_text = config_file.read().decode('utf-8', errors='strict')
if not config_text.strip():
return None, "配置文件为空"
config_data = yaml.safe_load(config_text)
# 基本验证
if not isinstance(config_data, dict):
return None, "配置文件格式错误:应为字典格式"
# 重置流用于后续保存
config_file.stream = BytesIO(config_text.encode('utf-8'))
return config_data, None
except UnicodeDecodeError as e:
return None, f"配置文件编码错误,请使用UTF-8编码: {str(e)}"
except yaml.YAMLError as e:
return None, f"YAML语法错误: {str(e)}"
except Exception as e:
return None, f"配置文件解析失败: {str(e)}"
def save_upload_file(file_obj, save_path, task_id, file_type="file"):
"""安全保存上传文件并提供详细错误信息"""
try:
# 检查磁盘空间
save_path.parent.mkdir(parents=True, exist_ok=True)
# 检查文件大小(如果有content_length)
if hasattr(file_obj, 'content_length') and file_obj.content_length:
max_size = current_app.config.get('MAX_CONTENT_LENGTH', 104857600) # 100MB
if file_obj.content_length > max_size:
return False, f"{file_type}文件过大 ({file_obj.content_length} bytes),最大限制: {max_size} bytes"
# 保存文件
file_obj.seek(0)
file_obj.save(str(save_path))
# 验证文件是否成功保存
if not save_path.exists():
return False, f"{file_type}文件保存失败:文件不存在"
if save_path.stat().st_size == 0:
return False, f"{file_type}文件保存失败:文件为空"
logger.info(f"Job {task_id}: {file_type} saved successfully - {save_path} ({save_path.stat().st_size} bytes)")
return True, None
except PermissionError:
return False, f"{file_type}保存失败:没有写入权限 - {save_path.parent}"
except OSError as e:
if e.errno == 28: # No space left on device
return False, f"{file_type}保存失败:磁盘空间不足"
elif e.errno == 36: # File name too long
return False, f"{file_type}文件名过长"
else:
return False, f"{file_type}保存失败:磁盘错误 ({e.errno})"
except Exception as e:
logger.error(f"Job {task_id}: Failed to save {file_type}: {str(e)}", exc_info=True)
return False, f"{file_type}保存失败:{str(e)}"
def format_upload_error(error_code, message, details=None, task_id=None):
"""统一的错误响应格式"""
response = {
'error': message,
'code': error_code
}
if details:
response['details'] = details
if task_id:
response['task_id'] = task_id
# 记录详细错误日志
log_level = 'warning' if error_code < 500 else 'error'
log_func = getattr(logger, log_level)
log_func(f"Upload error [{error_code}]: {message}" + (f" - Details: {details}" if details else ""))
return _format_response(error_code, message, response if details else None)
# Create blueprint
upload_bp = Blueprint('upload', __name__, url_prefix='/upload')
@ -25,97 +160,80 @@ upload_bp = Blueprint('upload', __name__, url_prefix='/upload')
@log_performance
def upload_file():
logger.info("Received upload request")
logger.info(f"Request content length: {request.content_length} bytes")
# Check if data file is present
# 1. 基础验证
if 'file' not in request.files:
logger.warning("Upload failed: No data file part in request")
return _format_response(400, "未找到数据文件部分")
return format_upload_error(400, "未找到数据文件", "请求中缺少 'file' 字段")
data_file = request.files['file']
config_file = request.files.get('config')
# Log file details
logger.info(f"Data file: {data_file.filename} (size: {getattr(data_file, 'content_length', 'unknown')} bytes)")
if config_file:
logger.info(f"Config file: {config_file.filename} (size: {getattr(config_file, 'content_length', 'unknown')} bytes)")
else:
logger.info("No custom config file provided, will use default")
# 2. 文件验证
validation_errors = validate_upload_files(data_file, config_file)
if validation_errors:
# 返回第一个错误
error = validation_errors[0]
return format_upload_error(400, error['error'], error['details'])
if data_file.filename == '':
logger.warning("Upload failed: No data file selected (empty filename)")
return _format_response(400, "未选择数据文件")
if not allowed_file(data_file.filename, ALLOWED_DATA_EXTENSIONS):
logger.warning(f"Upload failed: Invalid data file type {data_file.filename} - allowed: {ALLOWED_DATA_EXTENSIONS}")
return _format_response(400, "无效的数据文件类型。只允许 .xlsx 和 .xls 格式。")
# Generate unique job ID
# 3. 生成任务ID
task_id = str(uuid.uuid4())
logger.info(f"Generated job ID: {task_id}")
# 1) Parse config content (parse in memory without saving first)
if config_file and config_file.filename != '':
if not allowed_file(config_file.filename, ALLOWED_CONFIG_EXTENSIONS):
return _format_response(400, "无效的配置文件类型。只允许 .yaml 和 .yml 格式。")
config_file.stream.seek(0)
config_text = config_file.read().decode('utf-8', errors='ignore')
try:
active_config = yaml.safe_load(config_text)
except Exception:
return _format_response(400, "配置文件解析失败")
# Reset stream for saving
config_file.stream = BytesIO(config_text.encode('utf-8'))
else:
default_config_path = Path(__file__).parent.parent / "gasflux_config.yaml"
with open(default_config_path, 'r', encoding='utf-8') as f:
active_config = yaml.safe_load(f)
# 4. 解析配置
active_config, config_error = parse_config_file(config_file)
if config_error:
return format_upload_error(400, "配置文件错误", config_error, task_id)
# 2) Create job directories based on INI configuration
upload_base = Path(current_app.config['UPLOAD_FOLDER'])
output_base = Path(current_app.config['OUTPUT_FOLDER'])
job_upload_dir = upload_base / task_id
job_output_dir = output_base / task_id
job_upload_dir.mkdir(parents=True, exist_ok=True)
job_output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Job {task_id}: Created directories - Upload: {job_upload_dir}, Output: {job_output_dir}")
# 5. 创建目录
try:
upload_base = Path(current_app.config['UPLOAD_FOLDER'])
output_base = Path(current_app.config['OUTPUT_FOLDER'])
job_upload_dir = upload_base / task_id
job_output_dir = output_base / task_id
# 3) Save data file to job_upload_dir
job_upload_dir.mkdir(parents=True, exist_ok=True)
job_output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Job {task_id}: Directories created successfully")
except Exception as e:
return format_upload_error(500, "目录创建失败", str(e), task_id)
# 6. 保存数据文件
data_filename = secure_filename(data_file.filename)
data_path = job_upload_dir / data_filename
try:
data_file.seek(0)
data_file.save(str(data_path))
logger.info(f"Job {task_id}: Data file saved successfully - Path: {data_path}")
except Exception as e:
logger.error(f"Job {task_id}: Failed to save data file {data_filename}: {str(e)}")
return _format_response(500, "保存数据文件失败")
# 4) Save config file to job_upload_dir
success, error_msg = save_upload_file(data_file, data_path, task_id, "数据文件")
if not success:
return format_upload_error(500, error_msg, task_id=task_id)
# 7. 保存配置文件
if config_file and config_file.filename != '':
config_filename = secure_filename(config_file.filename)
config_path = job_upload_dir / config_filename
try:
config_file.seek(0)
config_file.save(str(config_path))
active_config_path = config_path
logger.info(f"Job {task_id}: Custom config saved successfully - Path: {config_path}")
except Exception as e:
logger.error(f"Job {task_id}: Failed to save config file {config_filename}: {str(e)}")
return _format_response(500, "保存配置文件失败")
else:
# Copy default config for record keeping
config_path = job_upload_dir / "config.yaml"
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(active_config, f, allow_unicode=True)
active_config_path = config_path
logger.info(f"Job {task_id}: Default config saved for record - Path: {config_path}")
# Initialize task status
update_task_status(task_id, TASK_STATUS_PENDING, "任务已加入处理队列", output_dir=str(job_output_dir))
logger.info(f"Job {task_id}: Task status initialized as PENDING")
success, error_msg = save_upload_file(config_file, config_path, task_id, "配置文件")
if not success:
return format_upload_error(500, error_msg, task_id=task_id)
else:
# 保存默认配置
config_path = job_upload_dir / "config.yaml"
try:
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(active_config, f, allow_unicode=True)
active_config_path = config_path
logger.info(f"Job {task_id}: Default config saved")
except Exception as e:
return format_upload_error(500, f"保存默认配置失败: {str(e)}", task_id=task_id)
# Start background processing
# 8. 初始化任务状态
try:
update_task_status(task_id, TASK_STATUS_PENDING, "任务已加入处理队列", output_dir=str(job_output_dir))
except Exception as e:
logger.error(f"Job {task_id}: Failed to update task status: {str(e)}")
# 继续处理,不返回错误
# 9. 启动后台处理
try:
thread = threading.Thread(
target=process_data_async,
@ -123,15 +241,16 @@ def upload_file():
)
thread.daemon = True
thread.start()
logger.info(f"Job {task_id}: Background processing thread started successfully")
logger.info(f"Job {task_id}: Background processing started")
except Exception as e:
logger.error(f"Job {task_id}: Failed to start background processing thread: {str(e)}")
logger.error(f"Job {task_id}: Failed to start processing thread: {str(e)}", exc_info=True)
update_task_status(task_id, TASK_STATUS_FAILED, error=str(e))
return _format_response(500, "启动处理失败")
return format_upload_error(500, "启动处理线程失败", str(e), task_id)
logger.info(f"Job {task_id}: Upload process completed successfully, returning job ID to client")
# 10. 返回成功响应
return _format_response(202, "任务已接受并加入处理队列", {
"status": "accepted",
"task_id": task_id,
"task_status_url": f"/task/{task_id}"
"task_status_url": f"/task/{task_id}",
"message": f"数据文件 '{data_filename}' 已上传,开始处理"
})