Spaces:
Running
Running
Commit ·
0703216
1
Parent(s): 69786b0
ok
Browse files
app/services/reconciliation_service.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import io
|
| 5 |
import os
|
|
|
|
| 6 |
import time
|
| 7 |
import unicodedata
|
| 8 |
from collections import Counter
|
|
@@ -18,8 +19,6 @@ from app.core.logger import get_logger
|
|
| 18 |
_logger = get_logger(__name__)
|
| 19 |
_settings = get_settings()
|
| 20 |
|
| 21 |
-
MAX_CONCURRENT_DOWNLOADS = 10
|
| 22 |
-
MAX_CONCURRENT_JOBS = 5
|
| 23 |
DOWNLOAD_TIMEOUT = 60
|
| 24 |
DOWNLOAD_MAX_RETRIES = 3
|
| 25 |
DOWNLOAD_BACKOFF_FACTOR = 0.5
|
|
@@ -45,13 +44,17 @@ def extract_extension(url: str) -> str:
|
|
| 45 |
return os.path.splitext(path)[1].lstrip(".").lower()
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
| 49 |
for col in df.columns:
|
| 50 |
if pd.api.types.is_string_dtype(df[col]):
|
| 51 |
s = df[col].astype(str).str.strip()
|
| 52 |
-
|
| 53 |
-
s = s.str.replace(r'\s+', ' ', regex=True)
|
| 54 |
-
df[col] = s.replace({'nan': pd.NA, 'None': pd.NA, '': pd.NA, 'null': pd.NA})
|
| 55 |
elif pd.api.types.is_numeric_dtype(df[col]):
|
| 56 |
df[col] = df[col].replace({pd.NA: None})
|
| 57 |
return df
|
|
@@ -65,10 +68,7 @@ async def download_file_with_retry(session: aiohttp.ClientSession, url: str, cor
|
|
| 65 |
return await response.read()
|
| 66 |
except aiohttp.ClientError as e:
|
| 67 |
wait_time = DOWNLOAD_BACKOFF_FACTOR * (2 ** attempt)
|
| 68 |
-
_logger.warning(
|
| 69 |
-
f"Download attempt {attempt+1} failed for {url}. Retrying in {wait_time}s. Error: {e}",
|
| 70 |
-
extra={"correlation_id": correlation_id}
|
| 71 |
-
)
|
| 72 |
await asyncio.sleep(wait_time)
|
| 73 |
raise DownloadError(f"Failed to download {url} after {DOWNLOAD_MAX_RETRIES} retries.")
|
| 74 |
|
|
@@ -85,7 +85,8 @@ def read_to_dataframe(data: bytes, ext: str, correlation_id: str) -> pd.DataFram
|
|
| 85 |
return pd.read_excel(io.BytesIO(data), engine="xlrd")
|
| 86 |
elif ext == "parquet":
|
| 87 |
return pd.read_parquet(io.BytesIO(data))
|
| 88 |
-
|
|
|
|
| 89 |
except Exception as e:
|
| 90 |
_logger.error(f"Failed to parse {ext} file: {str(e)}", extra={"correlation_id": correlation_id})
|
| 91 |
raise ReconciliationError(f"Corrupted or unparseable file: {str(e)}")
|
|
@@ -97,7 +98,6 @@ def compare_schemas(df_src: pd.DataFrame, df_dst: pd.DataFrame) -> Dict[str, Any
|
|
| 97 |
missing_in_src = list(dst_cols - src_cols)
|
| 98 |
missing_in_dst = list(src_cols - dst_cols)
|
| 99 |
type_mismatches = []
|
| 100 |
-
|
| 101 |
common_cols = src_cols & dst_cols
|
| 102 |
for col in common_cols:
|
| 103 |
if df_src[col].dtype != df_dst[col].dtype:
|
|
@@ -106,7 +106,6 @@ def compare_schemas(df_src: pd.DataFrame, df_dst: pd.DataFrame) -> Dict[str, Any
|
|
| 106 |
"source_type": str(df_src[col].dtype),
|
| 107 |
"destination_type": str(df_dst[col].dtype)
|
| 108 |
})
|
| 109 |
-
|
| 110 |
fully_match = not missing_in_src and not missing_in_dst and not type_mismatches
|
| 111 |
return {
|
| 112 |
"fully_match": fully_match,
|
|
@@ -121,22 +120,19 @@ def compare_schemas(df_src: pd.DataFrame, df_dst: pd.DataFrame) -> Dict[str, Any
|
|
| 121 |
def compare_rows(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> Tuple[int, int, int, int]:
|
| 122 |
src_subset = df_src[common_cols].astype(str).agg('|'.join, axis=1)
|
| 123 |
dst_subset = df_dst[common_cols].astype(str).agg('|'.join, axis=1)
|
| 124 |
-
|
| 125 |
src_counts = Counter(src_subset)
|
| 126 |
dst_counts = Counter(dst_subset)
|
| 127 |
-
|
| 128 |
identical = sum(min(count, dst_counts.get(row, 0)) for row, count in src_counts.items())
|
| 129 |
missing = len(df_src) - identical
|
| 130 |
extra = len(df_dst) - identical
|
| 131 |
-
|
| 132 |
return identical, 0, missing, extra
|
| 133 |
|
| 134 |
|
| 135 |
def reconcile_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
|
| 136 |
results = []
|
| 137 |
for col in common_cols:
|
| 138 |
-
src_nn = int(df_src[col].
|
| 139 |
-
dst_nn = int(df_dst[col].
|
| 140 |
status = "fully_match" if src_nn == dst_nn else "partial_match"
|
| 141 |
results.append({
|
| 142 |
"column_name": col,
|
|
@@ -151,51 +147,51 @@ def reconcile_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: L
|
|
| 151 |
|
| 152 |
|
| 153 |
def reconcile_numeric_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str], numeric_keywords: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
|
| 154 |
-
|
|
|
|
| 155 |
results = []
|
| 156 |
-
|
| 157 |
for col in common_cols:
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
})
|
| 190 |
return results
|
| 191 |
|
| 192 |
|
| 193 |
def reconcile_date_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
|
| 194 |
results = []
|
| 195 |
for col in common_cols:
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
| 199 |
results.append({
|
| 200 |
"column_name": col,
|
| 201 |
"status": "fully_match" if df_src[col].equals(df_dst[col]) else "partial_match",
|
|
@@ -238,7 +234,7 @@ def analyze_missing_data(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols
|
|
| 238 |
return results
|
| 239 |
|
| 240 |
|
| 241 |
-
def
|
| 242 |
return {
|
| 243 |
"job_id": job_id,
|
| 244 |
"source_file": src_url,
|
|
@@ -248,13 +244,11 @@ def _build_failed_pair(job_id: str, src_url: str, dst_url: str, started_at: str,
|
|
| 248 |
"processing_time_ms": 0,
|
| 249 |
"status": status,
|
| 250 |
"summary": {"overall_status": status, "overall_match_percentage": 0.0},
|
| 251 |
-
"schema": {"fully_match": False, "source_columns": 0, "destination_columns": 0},
|
| 252 |
-
"columns": [],
|
| 253 |
-
"
|
| 254 |
-
"
|
| 255 |
-
"
|
| 256 |
-
"missing_values": [],
|
| 257 |
-
"errors": errors
|
| 258 |
}
|
| 259 |
|
| 260 |
|
|
@@ -274,15 +268,15 @@ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: by
|
|
| 274 |
|
| 275 |
df_src = normalize_dataframe(df_src.copy())
|
| 276 |
df_dst = normalize_dataframe(df_dst.copy())
|
| 277 |
-
df_src
|
| 278 |
-
df_dst
|
| 279 |
|
| 280 |
schema_report = compare_schemas(df_src, df_dst)
|
| 281 |
if not schema_report["fully_match"]:
|
| 282 |
status = "SCHEMA_MISMATCH"
|
| 283 |
warnings.append("Schema mismatch detected.")
|
| 284 |
|
| 285 |
-
common_cols =
|
| 286 |
|
| 287 |
identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
|
| 288 |
|
|
@@ -326,13 +320,16 @@ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: by
|
|
| 326 |
summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
|
| 327 |
schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
|
| 328 |
|
|
|
|
|
|
|
|
|
|
| 329 |
return {
|
| 330 |
"job_id": job_id,
|
| 331 |
"source_file": src_url,
|
| 332 |
"destination_file": dst_url,
|
| 333 |
"started_at": started_at,
|
| 334 |
-
"completed_at":
|
| 335 |
-
"processing_time_ms":
|
| 336 |
"status": status,
|
| 337 |
"summary": summary,
|
| 338 |
"schema": schema_report,
|
|
@@ -349,16 +346,23 @@ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: by
|
|
| 349 |
async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
|
| 350 |
src_url = pair["source"]
|
| 351 |
dst_url = pair["destination"]
|
|
|
|
| 352 |
started_at = datetime.utcnow().isoformat()
|
| 353 |
|
| 354 |
src_ext = extract_extension(src_url)
|
| 355 |
dst_ext = extract_extension(dst_url)
|
| 356 |
|
| 357 |
if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
|
| 358 |
-
return
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
if src_ext != dst_ext:
|
| 361 |
-
return
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
async with aiohttp.ClientSession() as session:
|
| 364 |
try:
|
|
@@ -368,9 +372,13 @@ async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Op
|
|
| 368 |
)
|
| 369 |
except DownloadError as e:
|
| 370 |
_logger.error(f"Download failed for job {job_id}: {str(e)}", extra={"correlation_id": job_id})
|
| 371 |
-
return
|
|
|
|
|
|
|
| 372 |
|
| 373 |
if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
|
| 374 |
-
return
|
|
|
|
|
|
|
| 375 |
|
| 376 |
return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, numeric_keywords)
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import io
|
| 5 |
import os
|
| 6 |
+
import re
|
| 7 |
import time
|
| 8 |
import unicodedata
|
| 9 |
from collections import Counter
|
|
|
|
| 19 |
_logger = get_logger(__name__)
|
| 20 |
_settings = get_settings()
|
| 21 |
|
|
|
|
|
|
|
| 22 |
DOWNLOAD_TIMEOUT = 60
|
| 23 |
DOWNLOAD_MAX_RETRIES = 3
|
| 24 |
DOWNLOAD_BACKOFF_FACTOR = 0.5
|
|
|
|
| 44 |
return os.path.splitext(path)[1].lstrip(".").lower()
|
| 45 |
|
| 46 |
|
| 47 |
+
def _normalize_str(x: str) -> str:
|
| 48 |
+
if not pd.notna(x) or x == 'nan':
|
| 49 |
+
return x
|
| 50 |
+
return re.sub(r'\s+', ' ', unicodedata.normalize('NFKC', x))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
| 54 |
for col in df.columns:
|
| 55 |
if pd.api.types.is_string_dtype(df[col]):
|
| 56 |
s = df[col].astype(str).str.strip()
|
| 57 |
+
df[col] = s.apply(_normalize_str).replace({'nan': pd.NA, 'None': pd.NA, '': pd.NA, 'null': pd.NA})
|
|
|
|
|
|
|
| 58 |
elif pd.api.types.is_numeric_dtype(df[col]):
|
| 59 |
df[col] = df[col].replace({pd.NA: None})
|
| 60 |
return df
|
|
|
|
| 68 |
return await response.read()
|
| 69 |
except aiohttp.ClientError as e:
|
| 70 |
wait_time = DOWNLOAD_BACKOFF_FACTOR * (2 ** attempt)
|
| 71 |
+
_logger.warning(f"Download attempt {attempt+1} failed for {url}. Retrying in {wait_time}s. Error: {e}", extra={"correlation_id": correlation_id})
|
|
|
|
|
|
|
|
|
|
| 72 |
await asyncio.sleep(wait_time)
|
| 73 |
raise DownloadError(f"Failed to download {url} after {DOWNLOAD_MAX_RETRIES} retries.")
|
| 74 |
|
|
|
|
| 85 |
return pd.read_excel(io.BytesIO(data), engine="xlrd")
|
| 86 |
elif ext == "parquet":
|
| 87 |
return pd.read_parquet(io.BytesIO(data))
|
| 88 |
+
else:
|
| 89 |
+
raise UnsupportedFormatError(f"File format '{ext}' is not supported.")
|
| 90 |
except Exception as e:
|
| 91 |
_logger.error(f"Failed to parse {ext} file: {str(e)}", extra={"correlation_id": correlation_id})
|
| 92 |
raise ReconciliationError(f"Corrupted or unparseable file: {str(e)}")
|
|
|
|
| 98 |
missing_in_src = list(dst_cols - src_cols)
|
| 99 |
missing_in_dst = list(src_cols - dst_cols)
|
| 100 |
type_mismatches = []
|
|
|
|
| 101 |
common_cols = src_cols & dst_cols
|
| 102 |
for col in common_cols:
|
| 103 |
if df_src[col].dtype != df_dst[col].dtype:
|
|
|
|
| 106 |
"source_type": str(df_src[col].dtype),
|
| 107 |
"destination_type": str(df_dst[col].dtype)
|
| 108 |
})
|
|
|
|
| 109 |
fully_match = not missing_in_src and not missing_in_dst and not type_mismatches
|
| 110 |
return {
|
| 111 |
"fully_match": fully_match,
|
|
|
|
| 120 |
def compare_rows(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> Tuple[int, int, int, int]:
|
| 121 |
src_subset = df_src[common_cols].astype(str).agg('|'.join, axis=1)
|
| 122 |
dst_subset = df_dst[common_cols].astype(str).agg('|'.join, axis=1)
|
|
|
|
| 123 |
src_counts = Counter(src_subset)
|
| 124 |
dst_counts = Counter(dst_subset)
|
|
|
|
| 125 |
identical = sum(min(count, dst_counts.get(row, 0)) for row, count in src_counts.items())
|
| 126 |
missing = len(df_src) - identical
|
| 127 |
extra = len(df_dst) - identical
|
|
|
|
| 128 |
return identical, 0, missing, extra
|
| 129 |
|
| 130 |
|
| 131 |
def reconcile_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
|
| 132 |
results = []
|
| 133 |
for col in common_cols:
|
| 134 |
+
src_nn = int(df_src[col].notna().sum())
|
| 135 |
+
dst_nn = int(df_dst[col].notna().sum())
|
| 136 |
status = "fully_match" if src_nn == dst_nn else "partial_match"
|
| 137 |
results.append({
|
| 138 |
"column_name": col,
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
def reconcile_numeric_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str], numeric_keywords: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
|
| 150 |
+
if numeric_keywords is None:
|
| 151 |
+
numeric_keywords = DEFAULT_NUMERIC_KEYWORDS
|
| 152 |
results = []
|
|
|
|
| 153 |
for col in common_cols:
|
| 154 |
+
is_numeric_name = any(kw in col.lower() for kw in numeric_keywords)
|
| 155 |
+
is_numeric_dtype = pd.api.types.is_numeric_dtype(df_src[col]) and pd.api.types.is_numeric_dtype(df_dst[col])
|
| 156 |
+
if is_numeric_name and is_numeric_dtype:
|
| 157 |
+
src_sum = df_src[col].sum()
|
| 158 |
+
dst_sum = df_dst[col].sum()
|
| 159 |
+
diff = src_sum - dst_sum
|
| 160 |
+
abs_diff = abs(diff)
|
| 161 |
+
pct_diff = (abs_diff / src_sum * 100) if src_sum != 0 else 0.0
|
| 162 |
+
src_nn = int(df_src[col].notna().sum())
|
| 163 |
+
dst_nn = int(df_dst[col].notna().sum())
|
| 164 |
+
status = "fully_match" if abs_diff < 1e-9 else "partial_match"
|
| 165 |
+
results.append({
|
| 166 |
+
"column_name": col,
|
| 167 |
+
"status": status,
|
| 168 |
+
"source_non_null": src_nn,
|
| 169 |
+
"destination_non_null": dst_nn,
|
| 170 |
+
"matching_values": src_nn,
|
| 171 |
+
"mismatching_values": abs(src_nn - dst_nn),
|
| 172 |
+
"difference_count": abs(src_nn - dst_nn),
|
| 173 |
+
"source_total": round(float(src_sum), 2),
|
| 174 |
+
"destination_total": round(float(dst_sum), 2),
|
| 175 |
+
"total_difference": round(float(diff), 2),
|
| 176 |
+
"absolute_difference": round(float(abs_diff), 2),
|
| 177 |
+
"percentage_difference": round(float(pct_diff), 5),
|
| 178 |
+
"source_min": round(float(df_src[col].min()), 2),
|
| 179 |
+
"source_max": round(float(df_src[col].max()), 2),
|
| 180 |
+
"source_avg": round(float(df_src[col].mean()), 2),
|
| 181 |
+
"dest_min": round(float(df_dst[col].min()), 2),
|
| 182 |
+
"dest_max": round(float(df_dst[col].max()), 2),
|
| 183 |
+
"dest_avg": round(float(df_dst[col].mean()), 2)
|
| 184 |
+
})
|
|
|
|
| 185 |
return results
|
| 186 |
|
| 187 |
|
| 188 |
def reconcile_date_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
|
| 189 |
results = []
|
| 190 |
for col in common_cols:
|
| 191 |
+
is_date = pd.api.types.is_datetime64_any_dtype(df_src[col]) and pd.api.types.is_datetime64_any_dtype(df_dst[col])
|
| 192 |
+
if is_date:
|
| 193 |
+
src_nn = int(df_src[col].notna().sum())
|
| 194 |
+
dst_nn = int(df_dst[col].notna().sum())
|
| 195 |
results.append({
|
| 196 |
"column_name": col,
|
| 197 |
"status": "fully_match" if df_src[col].equals(df_dst[col]) else "partial_match",
|
|
|
|
| 234 |
return results
|
| 235 |
|
| 236 |
|
| 237 |
+
def _build_failed_pair_result(job_id: str, src_url: str, dst_url: str, started_at: str, status: str, errors: List[str], schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 238 |
return {
|
| 239 |
"job_id": job_id,
|
| 240 |
"source_file": src_url,
|
|
|
|
| 244 |
"processing_time_ms": 0,
|
| 245 |
"status": status,
|
| 246 |
"summary": {"overall_status": status, "overall_match_percentage": 0.0},
|
| 247 |
+
"schema": schema or {"fully_match": False, "source_columns": 0, "destination_columns": 0},
|
| 248 |
+
"columns": [], "numeric_columns": [], "date_columns": [],
|
| 249 |
+
"duplicates": {}, "missing_values": [],
|
| 250 |
+
"errors": errors,
|
| 251 |
+
"warnings": []
|
|
|
|
|
|
|
| 252 |
}
|
| 253 |
|
| 254 |
|
|
|
|
| 268 |
|
| 269 |
df_src = normalize_dataframe(df_src.copy())
|
| 270 |
df_dst = normalize_dataframe(df_dst.copy())
|
| 271 |
+
df_src.infer_objects()
|
| 272 |
+
df_dst.infer_objects()
|
| 273 |
|
| 274 |
schema_report = compare_schemas(df_src, df_dst)
|
| 275 |
if not schema_report["fully_match"]:
|
| 276 |
status = "SCHEMA_MISMATCH"
|
| 277 |
warnings.append("Schema mismatch detected.")
|
| 278 |
|
| 279 |
+
common_cols = df_src.columns.intersection(df_dst.columns).tolist()
|
| 280 |
|
| 281 |
identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
|
| 282 |
|
|
|
|
| 320 |
summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
|
| 321 |
schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
|
| 322 |
|
| 323 |
+
completed_at = datetime.utcnow().isoformat()
|
| 324 |
+
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
| 325 |
+
|
| 326 |
return {
|
| 327 |
"job_id": job_id,
|
| 328 |
"source_file": src_url,
|
| 329 |
"destination_file": dst_url,
|
| 330 |
"started_at": started_at,
|
| 331 |
+
"completed_at": completed_at,
|
| 332 |
+
"processing_time_ms": processing_time_ms,
|
| 333 |
"status": status,
|
| 334 |
"summary": summary,
|
| 335 |
"schema": schema_report,
|
|
|
|
| 346 |
async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
|
| 347 |
src_url = pair["source"]
|
| 348 |
dst_url = pair["destination"]
|
| 349 |
+
|
| 350 |
started_at = datetime.utcnow().isoformat()
|
| 351 |
|
| 352 |
src_ext = extract_extension(src_url)
|
| 353 |
dst_ext = extract_extension(dst_url)
|
| 354 |
|
| 355 |
if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
|
| 356 |
+
return _build_failed_pair_result(
|
| 357 |
+
job_id, src_url, dst_url, started_at, "FAILED",
|
| 358 |
+
[f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"]
|
| 359 |
+
)
|
| 360 |
|
| 361 |
if src_ext != dst_ext:
|
| 362 |
+
return _build_failed_pair_result(
|
| 363 |
+
job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH",
|
| 364 |
+
[f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"]
|
| 365 |
+
)
|
| 366 |
|
| 367 |
async with aiohttp.ClientSession() as session:
|
| 368 |
try:
|
|
|
|
| 372 |
)
|
| 373 |
except DownloadError as e:
|
| 374 |
_logger.error(f"Download failed for job {job_id}: {str(e)}", extra={"correlation_id": job_id})
|
| 375 |
+
return _build_failed_pair_result(
|
| 376 |
+
job_id, src_url, dst_url, started_at, "FAILED", [str(e)]
|
| 377 |
+
)
|
| 378 |
|
| 379 |
if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
|
| 380 |
+
return _build_failed_pair_result(
|
| 381 |
+
job_id, src_url, dst_url, started_at, "FAILED", ["File size exceeds maximum allowed limit"]
|
| 382 |
+
)
|
| 383 |
|
| 384 |
return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, numeric_keywords)
|