feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import math
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
def compute_file_sha256(filepath: Path, buffer_size: int = 1024 * 1024) -> str:
|
||||
"""Calculates full SHA-256 checksum of a file using streaming buffers to avoid memory overhead."""
|
||||
hasher = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(buffer_size)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
class FileChunker:
|
||||
"""Handles splitting large files into discrete chunks and calculating block hashes."""
|
||||
|
||||
def __init__(self, filepath: Path, chunk_size: int = 4 * 1024 * 1024):
|
||||
self.filepath = Path(filepath).resolve()
|
||||
if not self.filepath.exists():
|
||||
raise FileNotFoundError(f"File {filepath} not found")
|
||||
|
||||
self.file_size = self.filepath.stat().st_size
|
||||
self.chunk_size = chunk_size
|
||||
self.total_chunks = max(1, math.ceil(self.file_size / self.chunk_size))
|
||||
self._cached_sha256 = None
|
||||
|
||||
@property
|
||||
def full_sha256(self) -> str:
|
||||
if self._cached_sha256 is None:
|
||||
self._cached_sha256 = compute_file_sha256(self.filepath)
|
||||
return self._cached_sha256
|
||||
|
||||
def get_chunk(self, chunk_index: int) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Reads the bytes for chunk `chunk_index` and returns (chunk_data, chunk_sha256).
|
||||
"""
|
||||
if chunk_index < 0 or chunk_index >= self.total_chunks:
|
||||
raise IndexError(f"Chunk index {chunk_index} out of bounds (total: {self.total_chunks})")
|
||||
|
||||
offset = chunk_index * self.chunk_size
|
||||
with open(self.filepath, "rb") as f:
|
||||
f.seek(offset)
|
||||
data = f.read(self.chunk_size)
|
||||
|
||||
chunk_sha256 = hashlib.sha256(data).hexdigest()
|
||||
return data, chunk_sha256
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AGENT_HOME = Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / "OnEverDrive"
|
||||
if not AGENT_HOME.exists():
|
||||
try:
|
||||
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
AGENT_HOME = Path(__file__).resolve().parent.parent / "data"
|
||||
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CONFIG_FILE = AGENT_HOME / "config.json"
|
||||
|
||||
class LocalFolderJob(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
name: str
|
||||
source_path: str
|
||||
file_patterns: str = "*.bak,*.mdf"
|
||||
schedule_interval_minutes: int = 60
|
||||
min_stable_seconds: int = 60
|
||||
is_active: bool = True
|
||||
last_backup_at: Optional[str] = None
|
||||
last_status: Optional[str] = "En espera"
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
server_url: str = "http://127.0.0.1:8000"
|
||||
client_code: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
device_token: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
chunk_size: int = 4 * 1024 * 1024 # 4 MB
|
||||
heartbeat_interval_seconds: int = 30
|
||||
min_stable_time_seconds: int = 60
|
||||
log_level: str = "INFO"
|
||||
local_folders: List[LocalFolderJob] = []
|
||||
|
||||
# Notification preferences (configurable from GUI and Tray)
|
||||
enable_notifications: bool = True
|
||||
notify_on_start: bool = True
|
||||
notify_on_complete: bool = True
|
||||
notify_on_error: bool = True
|
||||
|
||||
def load_config() -> AgentConfig:
|
||||
"""Loads agent configuration from disk or returns default configuration."""
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return AgentConfig(**data)
|
||||
except Exception:
|
||||
pass
|
||||
return AgentConfig()
|
||||
|
||||
def save_config(config: AgentConfig) -> None:
|
||||
"""Persists agent configuration to disk."""
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(config.model_dump(), f, indent=2)
|
||||
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import time
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
def is_file_locked(filepath: Path) -> bool:
|
||||
"""
|
||||
Checks if a file is locked exclusively by another process (e.g. SQL Server writing .bak).
|
||||
Attempts to open the file with read-shared permissions.
|
||||
"""
|
||||
if not filepath.exists() or not filepath.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
# On Windows, try opening in append/read mode to detect exclusive write lock
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
return False
|
||||
except (PermissionError, IOError, OSError):
|
||||
return True
|
||||
|
||||
def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval_seconds: float = 0.5) -> bool:
|
||||
"""
|
||||
Ensures that a file is not actively growing or being modified.
|
||||
Verifies that modification timestamp and size are stable.
|
||||
"""
|
||||
if is_file_locked(filepath):
|
||||
return False
|
||||
|
||||
try:
|
||||
stat_initial = filepath.stat()
|
||||
initial_size = stat_initial.st_size
|
||||
initial_mtime = stat_initial.st_mtime
|
||||
|
||||
# Check if the file was modified very recently compared to current time
|
||||
current_time = time.time()
|
||||
if (current_time - initial_mtime) < min_stable_seconds:
|
||||
# File was modified less than min_stable_seconds ago; perform sample check
|
||||
time.sleep(sample_interval_seconds)
|
||||
stat_second = filepath.stat()
|
||||
if stat_second.st_size != initial_size or stat_second.st_mtime != initial_mtime:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
class DirectoryScanner:
|
||||
"""Scans Windows source paths for files matching specific backup patterns."""
|
||||
|
||||
def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60):
|
||||
self.source_path = Path(source_path).resolve()
|
||||
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
||||
self.min_stable_seconds = min_stable_seconds
|
||||
|
||||
def scan(self) -> List[Path]:
|
||||
"""Returns list of all matching files that are stable and ready for backup."""
|
||||
if not self.source_path.exists():
|
||||
return []
|
||||
|
||||
matched_files: List[Path] = []
|
||||
|
||||
if self.source_path.is_file():
|
||||
if self._matches_patterns(self.source_path.name):
|
||||
matched_files.append(self.source_path)
|
||||
return matched_files
|
||||
|
||||
for root, _, files in os.walk(self.source_path):
|
||||
for file in files:
|
||||
if self._matches_patterns(file):
|
||||
full_path = Path(root) / file
|
||||
matched_files.append(full_path)
|
||||
|
||||
return matched_files
|
||||
|
||||
def _matches_patterns(self, filename: str) -> bool:
|
||||
if not self.patterns or "*" in self.patterns:
|
||||
return True
|
||||
for pattern in self.patterns:
|
||||
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,193 @@
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from agent.config import AgentConfig, load_config, save_config, LocalFolderJob
|
||||
from agent.scanner import DirectoryScanner, is_file_stable
|
||||
from agent.chunker import compute_file_sha256
|
||||
from agent.uploader import ChunkUploader
|
||||
from agent.state_db import state_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("OnEverAgent")
|
||||
|
||||
class AgentDaemon:
|
||||
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[AgentConfig] = None,
|
||||
on_started: Optional[Callable[[str, int], None]] = None,
|
||||
on_progress: Optional[Callable[[str, int, int, float], None]] = None,
|
||||
on_completed: Optional[Callable[[str, str, int], None]] = None,
|
||||
on_error: Optional[Callable[[str, str], None]] = None,
|
||||
on_status: Optional[Callable[[str, str], None]] = None
|
||||
):
|
||||
self.config = config or load_config()
|
||||
self.running = False
|
||||
self.uploader = ChunkUploader(self.config)
|
||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Event callbacks
|
||||
self.on_started = on_started
|
||||
self.on_progress = on_progress
|
||||
self.on_completed = on_completed
|
||||
self.on_error = on_error
|
||||
self.on_status = on_status
|
||||
|
||||
def start(self):
|
||||
self.config = load_config()
|
||||
if not self.config.device_id or not self.config.device_token:
|
||||
logger.warning("Agent is not registered yet. Waiting for registration.")
|
||||
if self.on_status:
|
||||
self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})")
|
||||
logger.info(f"Target Server: {self.config.server_url}")
|
||||
|
||||
if self.on_status:
|
||||
self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})")
|
||||
|
||||
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
||||
self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True)
|
||||
|
||||
self._heartbeat_thread.start()
|
||||
self._worker_thread.start()
|
||||
|
||||
def stop(self):
|
||||
logger.info("Stopping agent daemon...")
|
||||
self.running = False
|
||||
if self.on_status:
|
||||
self.on_status("PAUSED", "Servicio en pausa.")
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
"X-Device-Id": self.config.device_id,
|
||||
"X-Device-Token": self.config.device_token
|
||||
}
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
while self.running:
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
logger.debug("Heartbeat acknowledged by server.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Heartbeat failed: {str(ex)}")
|
||||
|
||||
time.sleep(self.config.heartbeat_interval_seconds)
|
||||
|
||||
def _backup_worker_loop(self):
|
||||
while self.running:
|
||||
try:
|
||||
self._run_backup_cycle()
|
||||
except Exception as ex:
|
||||
logger.error(f"Error during backup cycle: {str(ex)}")
|
||||
|
||||
time.sleep(30)
|
||||
|
||||
def _run_backup_cycle(self):
|
||||
self.config = load_config()
|
||||
self.uploader.config = self.config
|
||||
|
||||
# 1. Fetch server-assigned jobs
|
||||
server_jobs = []
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
server_jobs = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Combine server jobs + user local folders
|
||||
all_jobs = []
|
||||
for sj in server_jobs:
|
||||
all_jobs.append({
|
||||
"job_id": sj.get("id"),
|
||||
"name": sj.get("name"),
|
||||
"source_path": sj.get("source_path"),
|
||||
"file_patterns": sj.get("file_patterns", "*.*"),
|
||||
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
|
||||
})
|
||||
|
||||
for lj in self.config.local_folders:
|
||||
if lj.is_active:
|
||||
all_jobs.append({
|
||||
"job_id": None,
|
||||
"local_job_id": lj.id,
|
||||
"name": lj.name,
|
||||
"source_path": lj.source_path,
|
||||
"file_patterns": lj.file_patterns,
|
||||
"min_stable_seconds": lj.min_stable_seconds
|
||||
})
|
||||
|
||||
# 3. Process jobs
|
||||
for job in all_jobs:
|
||||
source_path = job["source_path"]
|
||||
file_patterns = job["file_patterns"]
|
||||
min_stable = job["min_stable_seconds"]
|
||||
job_name = job["name"]
|
||||
|
||||
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
||||
files = scanner.scan()
|
||||
|
||||
for filepath in files:
|
||||
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||
continue
|
||||
|
||||
current_sha = compute_file_sha256(filepath)
|
||||
if state_db.is_file_already_backed_up(str(filepath), current_sha):
|
||||
continue
|
||||
|
||||
file_size_bytes = filepath.stat().st_size
|
||||
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
||||
|
||||
# Single notification when the process starts
|
||||
if self.on_started:
|
||||
self.on_started(filepath.name, file_size_bytes)
|
||||
|
||||
def on_chunk_progress(done, total, pct):
|
||||
if self.on_progress:
|
||||
self.on_progress(filepath.name, done, total, pct)
|
||||
|
||||
try:
|
||||
res = self.uploader.upload_file(
|
||||
filepath,
|
||||
job_id=job.get("job_id"),
|
||||
progress_callback=on_chunk_progress
|
||||
)
|
||||
logger.info(f"Successfully backed up {filepath.name}!")
|
||||
|
||||
# Update local folder last backup status
|
||||
if "local_job_id" in job:
|
||||
for folder in self.config.local_folders:
|
||||
if folder.id == job["local_job_id"]:
|
||||
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
folder.last_status = "Backup Exitoso"
|
||||
save_config(self.config)
|
||||
|
||||
# Single notification when the process finishes
|
||||
if self.on_completed:
|
||||
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
||||
if self.on_error:
|
||||
self.on_error(filepath.name, str(ex))
|
||||
@@ -0,0 +1,102 @@
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from agent.config import AGENT_HOME
|
||||
|
||||
STATE_DB_PATH = AGENT_HOME / "agent_state.db"
|
||||
|
||||
class StateDatabase:
|
||||
"""Local SQLite database for agent offline resiliency, session resume tracking and file caching."""
|
||||
|
||||
def __init__(self, db_path: Path = STATE_DB_PATH):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(str(self.db_path))
|
||||
|
||||
def _init_db(self):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS upload_sessions (
|
||||
filepath TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
session_code TEXT NOT NULL,
|
||||
total_chunks INTEGER NOT NULL,
|
||||
chunk_size INTEGER NOT NULL,
|
||||
completed_chunks INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
last_updated TIMESTAMP NOT NULL
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS completed_files (
|
||||
filepath TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
job_id INTEGER,
|
||||
last_backup_time TIMESTAMP NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def save_session(
|
||||
self,
|
||||
filepath: str,
|
||||
sha256: str,
|
||||
session_code: str,
|
||||
total_chunks: int,
|
||||
chunk_size: int,
|
||||
status: str = "UPLOADING"
|
||||
):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO upload_sessions
|
||||
(filepath, sha256, session_code, total_chunks, chunk_size, status, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (filepath, sha256, session_code, total_chunks, chunk_size, status, now))
|
||||
conn.commit()
|
||||
|
||||
def get_session(self, filepath: str) -> Optional[Dict[str, Any]]:
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT filepath, sha256, session_code, total_chunks, chunk_size, completed_chunks, status FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
"filepath": row[0],
|
||||
"sha256": row[1],
|
||||
"session_code": row[2],
|
||||
"total_chunks": row[3],
|
||||
"chunk_size": row[4],
|
||||
"completed_chunks": row[5],
|
||||
"status": row[6]
|
||||
}
|
||||
return None
|
||||
|
||||
def mark_session_completed(self, filepath: str, sha256: str, file_size: int, job_id: Optional[int] = None):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cursor.execute("DELETE FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO completed_files (filepath, sha256, file_size, job_id, last_backup_time)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (filepath, sha256, file_size, job_id, now))
|
||||
conn.commit()
|
||||
|
||||
def is_file_already_backed_up(self, filepath: str, current_sha256: str) -> bool:
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT sha256 FROM completed_files WHERE filepath = ?", (filepath,))
|
||||
row = cursor.fetchone()
|
||||
if row and row[0] == current_sha256:
|
||||
return True
|
||||
return False
|
||||
|
||||
state_db = StateDatabase()
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from agent.config import AgentConfig, load_config
|
||||
from agent.chunker import FileChunker
|
||||
from agent.state_db import state_db
|
||||
|
||||
class ChunkUploader:
|
||||
"""HTTP/HTTPS Chunk transfer client with automatic resumption and retry backoff."""
|
||||
|
||||
def __init__(self, config: Optional[AgentConfig] = None):
|
||||
self.config = config or load_config()
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
if not self.config.device_id or not self.config.device_token:
|
||||
raise ValueError("Agent is not registered. Run 'agent_cli.py register' first.")
|
||||
return {
|
||||
"X-Device-Id": self.config.device_id,
|
||||
"X-Device-Token": self.config.device_token
|
||||
}
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
filepath: Path,
|
||||
job_id: Optional[int] = None,
|
||||
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Uploads a file chunk by chunk. If interrupted, querying the server will return
|
||||
already received chunks, allowing immediate resumption without re-uploading completed parts.
|
||||
"""
|
||||
filepath = Path(filepath).resolve()
|
||||
chunker = FileChunker(filepath=filepath, chunk_size=self.config.chunk_size)
|
||||
full_sha256 = chunker.full_sha256
|
||||
filename = filepath.name
|
||||
|
||||
headers = self._get_headers()
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
|
||||
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
|
||||
# 1. Initialize or resume upload session
|
||||
init_payload = {
|
||||
"filename": filename,
|
||||
"file_size": chunker.file_size,
|
||||
"sha256": full_sha256,
|
||||
"chunk_size": chunker.chunk_size,
|
||||
"job_id": job_id
|
||||
}
|
||||
|
||||
resp = client.post("/api/upload/session", json=init_payload)
|
||||
resp.raise_for_status()
|
||||
session_data = resp.json()
|
||||
|
||||
session_code = session_data["session_code"]
|
||||
total_chunks = session_data["total_chunks"]
|
||||
received_chunks = set(session_data.get("received_chunks", []))
|
||||
|
||||
state_db.save_session(
|
||||
filepath=str(filepath),
|
||||
sha256=full_sha256,
|
||||
session_code=session_code,
|
||||
total_chunks=total_chunks,
|
||||
chunk_size=chunker.chunk_size,
|
||||
status="UPLOADING"
|
||||
)
|
||||
|
||||
# 2. Upload missing chunks
|
||||
missing_chunks = [i for i in range(total_chunks) if i not in received_chunks]
|
||||
|
||||
for idx in missing_chunks:
|
||||
chunk_bytes, chunk_hash = chunker.get_chunk(idx)
|
||||
|
||||
# Retry loop with exponential backoff for network resilience
|
||||
success = False
|
||||
attempt = 0
|
||||
while not success and attempt < max_retries:
|
||||
try:
|
||||
chunk_headers = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Chunk-Index": str(idx),
|
||||
"X-Chunk-SHA256": chunk_hash
|
||||
}
|
||||
c_resp = client.post(
|
||||
f"/api/upload/{session_code}/chunk?chunk_index={idx}&chunk_sha256={chunk_hash}",
|
||||
content=chunk_bytes,
|
||||
headers=chunk_headers
|
||||
)
|
||||
c_resp.raise_for_status()
|
||||
success = True
|
||||
except Exception as ex:
|
||||
attempt += 1
|
||||
if attempt >= max_retries:
|
||||
raise ConnectionError(f"Failed to upload chunk {idx} after {max_retries} attempts: {str(ex)}")
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
received_chunks.add(idx)
|
||||
if progress_callback:
|
||||
pct = round((len(received_chunks) / total_chunks) * 100, 2)
|
||||
progress_callback(len(received_chunks), total_chunks, pct)
|
||||
|
||||
# 3. Complete and verify full file assembly on server
|
||||
complete_resp = client.post(f"/api/upload/{session_code}/complete")
|
||||
complete_resp.raise_for_status()
|
||||
result = complete_resp.json()
|
||||
|
||||
# 4. Update local state database
|
||||
state_db.mark_session_completed(
|
||||
filepath=str(filepath),
|
||||
sha256=full_sha256,
|
||||
file_size=chunker.file_size,
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user