validops-east-1 commited on
Commit
0703216
·
1 Parent(s): 69786b0
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
- s = s.map(lambda x: unicodedata.normalize('NFKC', x) if x != 'nan' else x)
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
- raise UnsupportedFormatError(f"File format '{ext}' is not supported.")
 
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].count())
139
- dst_nn = int(df_dst[col].count())
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
- numeric_keywords = numeric_keywords or DEFAULT_NUMERIC_KEYWORDS
 
155
  results = []
156
-
157
  for col in common_cols:
158
- if any(kw in col.lower() for kw in numeric_keywords):
159
- if pd.api.types.is_numeric_dtype(df_src[col]) and pd.api.types.is_numeric_dtype(df_dst[col]):
160
- src_sum = df_src[col].sum()
161
- dst_sum = df_dst[col].sum()
162
- diff = src_sum - dst_sum
163
- abs_diff = abs(diff)
164
- pct_diff = (abs_diff / src_sum * 100) if src_sum != 0 else 0.0
165
-
166
- src_nn = int(df_src[col].count())
167
- dst_nn = int(df_dst[col].count())
168
- status = "fully_match" if abs_diff < 1e-9 else "partial_match"
169
-
170
- results.append({
171
- "column_name": col,
172
- "status": status,
173
- "source_non_null": src_nn,
174
- "destination_non_null": dst_nn,
175
- "matching_values": src_nn,
176
- "mismatching_values": abs(src_nn - dst_nn),
177
- "difference_count": abs(src_nn - dst_nn),
178
- "source_total": round(float(src_sum), 2),
179
- "destination_total": round(float(dst_sum), 2),
180
- "total_difference": round(float(diff), 2),
181
- "absolute_difference": round(float(abs_diff), 2),
182
- "percentage_difference": round(float(pct_diff), 5),
183
- "source_min": round(float(df_src[col].min()), 2),
184
- "source_max": round(float(df_src[col].max()), 2),
185
- "source_avg": round(float(df_src[col].mean()), 2),
186
- "dest_min": round(float(df_dst[col].min()), 2),
187
- "dest_max": round(float(df_dst[col].max()), 2),
188
- "dest_avg": round(float(df_dst[col].mean()), 2)
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
- if pd.api.types.is_datetime64_any_dtype(df_src[col]) and pd.api.types.is_datetime64_any_dtype(df_dst[col]):
197
- src_nn = int(df_src[col].count())
198
- dst_nn = int(df_dst[col].count())
 
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 _build_failed_pair(job_id: str, src_url: str, dst_url: str, started_at: str, status: str, errors: List[str]) -> Dict[str, Any]:
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
- "numeric_columns": [],
254
- "date_columns": [],
255
- "duplicates": {},
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 = df_src.infer_objects()
278
- df_dst = df_dst.infer_objects()
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 = list(df_src.columns.intersection(df_dst.columns))
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": datetime.utcnow().isoformat(),
335
- "processing_time_ms": round((time.time() - start_time) * 1000, 2),
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 _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", [f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"])
 
 
 
359
 
360
  if src_ext != dst_ext:
361
- return _build_failed_pair(job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH", [f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"])
 
 
 
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 _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", [str(e)])
 
 
372
 
373
  if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
374
- return _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", ["File size exceeds maximum allowed limit"])
 
 
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)