jw-search / backend /batch_processor.py
jw-tools's picture
Refactor v2: stability, hosted hardening, porter+timestamps, dual shards (480x270 video + pub), analytics
19b362c verified
Raw
History Blame Contribute Delete
15.7 kB
"""
Batch Processing Enhancements for Video Processing
Provides:
1. Resume checkpoint system
2. Error summary with detailed failure tracking
3. Batch INSERT transactions for database efficiency
4. Memory monitoring
5. Structured per-run logging
6. Performance metrics and timing
Usage:
from batch_processor import BatchProcessor
processor = BatchProcessor()
processor.process_videos(video_keys, label="720p")
"""
import os
import json
import time
import gc
from datetime import datetime
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, asdict
from contextlib import contextmanager
from runtime_paths import checkpoint_file, log_file
from utils import atomic_write_json
# Try to import psutil for memory monitoring
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
print("Warning: psutil not installed. Memory monitoring disabled.")
@dataclass
class VideoProcessingResult:
"""Result of processing a single video."""
natural_key: str
status: str # 'success', 'error', 'skipped'
stage_completed: str # 'thumbnails', 'embeddings', 'faces', 'complete'
error_message: Optional[str]
duration_seconds: float
thumbnail_count: int
embedding_count: int
face_count: int
timestamp: str
@dataclass
class BatchStats:
"""Statistics for a batch processing run."""
run_id: str
started_at: str
completed_at: Optional[str]
total_videos: int
processed: int
skipped: int
failed: int
total_thumbnails: int
total_embeddings: int
total_faces: int
avg_time_per_video: float
errors: List[Dict[str, str]]
class StructuredLogger:
"""Structured logging with per-run log files."""
def __init__(self, run_id: str, log_dir: str | None = None):
self.run_id = run_id
self.log_dir = log_dir or os.path.dirname(log_file())
os.makedirs(self.log_dir, exist_ok=True)
# Create run-specific log file
self.log_file = os.path.join(self.log_dir, f"processing_{run_id}.log")
self.error_file = os.path.join(self.log_dir, f"errors_{run_id}.csv")
self.metrics_file = os.path.join(self.log_dir, f"metrics_{run_id}.json")
# Initialize error CSV
with open(self.error_file, 'w') as f:
f.write("timestamp,natural_key,stage,error_type,error_message\n")
self.log("INFO", f"Batch processing run started: {run_id}")
def log(self, level: str, message: str, natural_key: str = None):
"""Log a message with timestamp and optional video key."""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
if natural_key:
entry = f"[{timestamp}] [{level}] [{natural_key}] {message}"
else:
entry = f"[{timestamp}] [{level}] {message}"
# Only print important messages to console
if level in ('ERROR', 'WARNING', 'INFO'):
print(entry)
# Always write to log file
with open(self.log_file, 'a') as f:
f.write(entry + "\n")
def log_error(self, natural_key: str, stage: str, error_type: str, error_message: str):
"""Log an error to both log file and error CSV."""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self.log("ERROR", f"Stage: {stage}, Type: {error_type}, Message: {error_message}", natural_key)
# Append to error CSV (escape commas in message)
safe_message = error_message.replace('"', '""').replace('\n', ' ')
with open(self.error_file, 'a') as f:
f.write(f'{timestamp},{natural_key},{stage},{error_type},"{safe_message}"\n')
def log_progress(self, current: int, total: int, natural_key: str, status: str, duration: float):
"""Log progress with ETA calculation."""
percent = (current / total) * 100
self.log("INFO", f"[{current}/{total}] ({percent:.1f}%) - {status} in {duration:.1f}s", natural_key)
def save_metrics(self, stats: BatchStats):
"""Save batch statistics to JSON file."""
atomic_write_json(self.metrics_file, asdict(stats), indent=2)
self.log("INFO", f"Metrics saved to {self.metrics_file}")
class CheckpointManager:
"""Manages processing checkpoints for resume capability."""
def __init__(self, run_id: str, checkpoint_dir: str | None = None):
self.run_id = run_id
self.checkpoint_dir = checkpoint_dir or os.path.dirname(checkpoint_file(run_id))
os.makedirs(self.checkpoint_dir, exist_ok=True)
self.checkpoint_file = (
os.path.join(self.checkpoint_dir, f"checkpoint_{run_id}.json")
if checkpoint_dir is not None
else checkpoint_file(run_id)
)
self.checkpoint_data = self._load_checkpoint()
def _load_checkpoint(self) -> Dict[str, Any]:
"""Load existing checkpoint if available."""
if os.path.exists(self.checkpoint_file):
try:
with open(self.checkpoint_file, 'r', encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(
f"Checkpoint file {self.checkpoint_file} is invalid or unreadable: {exc}"
) from exc
return {
"run_id": self.run_id,
"started_at": datetime.now().isoformat(),
"processed_keys": [],
"failed_keys": [],
"skipped_keys": [],
"last_key": None
}
def mark_processed(self, natural_key: str):
"""Mark a video as successfully processed."""
if natural_key not in self.checkpoint_data["processed_keys"]:
self.checkpoint_data["processed_keys"].append(natural_key)
self.checkpoint_data["last_key"] = natural_key
self._save_checkpoint()
def mark_failed(self, natural_key: str):
"""Mark a video as failed."""
if natural_key not in self.checkpoint_data["failed_keys"]:
self.checkpoint_data["failed_keys"].append(natural_key)
self.checkpoint_data["last_key"] = natural_key
self._save_checkpoint()
def mark_skipped(self, natural_key: str):
"""Mark a video as skipped (already processed)."""
if natural_key not in self.checkpoint_data["skipped_keys"]:
self.checkpoint_data["skipped_keys"].append(natural_key)
self._save_checkpoint()
def is_processed(self, natural_key: str) -> bool:
"""Check if a video was already processed in this run."""
return natural_key in self.checkpoint_data["processed_keys"]
def get_pending_keys(self, all_keys: List[str]) -> List[str]:
"""Get keys that haven't been processed yet."""
processed = set(self.checkpoint_data["processed_keys"])
failed = set(self.checkpoint_data["failed_keys"])
skipped = set(self.checkpoint_data["skipped_keys"])
completed = processed | failed | skipped
return [k for k in all_keys if k not in completed]
def _save_checkpoint(self):
"""Save checkpoint to disk."""
self.checkpoint_data["updated_at"] = datetime.now().isoformat()
atomic_write_json(self.checkpoint_file, self.checkpoint_data, indent=2)
def get_summary(self) -> Dict[str, int]:
"""Get summary of processing status."""
return {
"processed": len(self.checkpoint_data["processed_keys"]),
"failed": len(self.checkpoint_data["failed_keys"]),
"skipped": len(self.checkpoint_data["skipped_keys"])
}
class MemoryMonitor:
"""Monitor system memory and manage resources."""
def __init__(self, min_free_mb: int = 2000, warning_threshold_mb: int = 4000):
self.min_free_mb = min_free_mb
self.warning_threshold_mb = warning_threshold_mb
self.last_check = 0
self.check_interval = 10 # seconds
def get_memory_status(self) -> Dict[str, Any]:
"""Get current memory status."""
if not HAS_PSUTIL:
return {"available": True, "free_mb": -1, "percent_used": -1}
mem = psutil.virtual_memory()
free_mb = mem.available / (1024 ** 2)
return {
"available": free_mb >= self.min_free_mb,
"free_mb": round(free_mb, 1),
"percent_used": mem.percent,
"total_mb": round(mem.total / (1024 ** 2), 1)
}
def ensure_memory_available(self, logger: StructuredLogger = None) -> bool:
"""
Check if enough memory is available, attempt cleanup if not.
Returns:
True if memory is available, False if critically low
"""
if not HAS_PSUTIL:
return True
status = self.get_memory_status()
if not status["available"]:
if logger:
logger.log("WARNING", f"Low memory: {status['free_mb']}MB free, attempting cleanup")
# Force garbage collection
gc.collect()
# Try to clear GPU cache if torch is available
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass
# Re-check after cleanup
status = self.get_memory_status()
if not status["available"]:
if logger:
logger.log("ERROR", f"Memory still low after cleanup: {status['free_mb']}MB free")
return False
elif status["free_mb"] < self.warning_threshold_mb:
if logger:
logger.log("WARNING", f"Memory getting low: {status['free_mb']}MB free")
return True
def should_check(self) -> bool:
"""Determine if it's time to check memory again."""
now = time.time()
if now - self.last_check > self.check_interval:
self.last_check = now
return True
return False
class PerformanceTracker:
"""Track timing for processing stages."""
def __init__(self):
self.stage_times = {}
self.video_times = []
self.current_video_start = None
self.current_stages = {}
def start_video(self, natural_key: str):
"""Start timing a video."""
self.current_video_start = time.time()
self.current_stages = {}
def start_stage(self, stage_name: str):
"""Start timing a processing stage."""
self.current_stages[stage_name] = time.time()
def end_stage(self, stage_name: str):
"""End timing a processing stage."""
if stage_name in self.current_stages:
duration = time.time() - self.current_stages[stage_name]
if stage_name not in self.stage_times:
self.stage_times[stage_name] = []
self.stage_times[stage_name].append(duration)
return duration
return 0
def end_video(self) -> float:
"""End timing for current video."""
if self.current_video_start:
duration = time.time() - self.current_video_start
self.video_times.append(duration)
return duration
return 0
def get_stats(self) -> Dict[str, Any]:
"""Get timing statistics."""
stats = {
"videos_timed": len(self.video_times),
"total_time_seconds": sum(self.video_times),
"avg_time_per_video": sum(self.video_times) / len(self.video_times) if self.video_times else 0,
"stages": {}
}
for stage, times in self.stage_times.items():
stats["stages"][stage] = {
"count": len(times),
"total_seconds": round(sum(times), 2),
"avg_seconds": round(sum(times) / len(times), 2) if times else 0,
"min_seconds": round(min(times), 2) if times else 0,
"max_seconds": round(max(times), 2) if times else 0
}
return stats
def get_eta(self, remaining_videos: int) -> str:
"""Estimate time remaining."""
if not self.video_times:
return "Unknown"
avg_time = sum(self.video_times) / len(self.video_times)
remaining_seconds = avg_time * remaining_videos
if remaining_seconds < 60:
return f"{int(remaining_seconds)} seconds"
elif remaining_seconds < 3600:
return f"{int(remaining_seconds / 60)} minutes"
elif remaining_seconds < 86400:
hours = remaining_seconds / 3600
return f"{hours:.1f} hours"
else:
days = remaining_seconds / 86400
return f"{days:.1f} days"
def generate_run_id() -> str:
"""Generate a unique run ID."""
return datetime.now().strftime('%Y%m%d_%H%M%S')
# ============================================================
# Helper function to get bottleneck analysis
# ============================================================
def analyze_processing_bottlenecks() -> Dict[str, Any]:
"""
Analyze the current processing pipeline for bottlenecks.
Returns:
Analysis of potential bottlenecks and recommendations
"""
bottlenecks = []
recommendations = []
# Check FFmpeg usage pattern
bottlenecks.append({
"component": "Thumbnail Resizing",
"issue": "FFmpeg called separately for each thumbnail (2 calls per thumbnail)",
"impact": "High - 100+ subprocess calls per video",
"fix": "Use single FFmpeg command with scale filter chain or parallel processing"
})
# Check database patterns
bottlenecks.append({
"component": "Database Writes",
"issue": "Each category/embedding is a separate INSERT with new connection",
"impact": "High - 3000+ connections per video at scale",
"fix": "Use batch transactions (one transaction per video, not per insert)"
})
# Check logging
bottlenecks.append({
"component": "Logging",
"issue": "Every image categorization logs full results",
"impact": "Medium - Disk I/O and log file bloat",
"fix": "Log only summaries and errors (implemented in StructuredLogger)"
})
# Model loading
bottlenecks.append({
"component": "Model Loading",
"issue": "Models are lazily loaded but should persist across videos",
"impact": "Low - Models stay in memory after first load",
"fix": "Verify models persist; consider explicit model manager"
})
recommendations = [
"Batch category/embedding inserts into one transaction per video",
"Replace individual FFmpeg calls with batch image processing",
"Set up per-run logging with StructuredLogger",
"Enable memory monitoring with MemoryMonitor",
"Use CheckpointManager for resume capability",
"Process 50-100 videos first to validate performance"
]
return {
"bottlenecks": bottlenecks,
"recommendations": recommendations,
"estimated_improvement": "40-60% reduction in processing time with batch optimizations"
}
if __name__ == "__main__":
# Print bottleneck analysis
print("=" * 60)
print("PROCESSING BOTTLENECK ANALYSIS")
print("=" * 60)
analysis = analyze_processing_bottlenecks()
print("\nBottlenecks Identified:")
for i, b in enumerate(analysis["bottlenecks"], 1):
print(f"\n{i}. {b['component']}")
print(f" Issue: {b['issue']}")
print(f" Impact: {b['impact']}")
print(f" Fix: {b['fix']}")
print("\n\nRecommendations:")
for r in analysis["recommendations"]:
print(f" • {r}")
print(f"\n{analysis['estimated_improvement']}")