Repository navigation
Expand file tree
/
Copy pathinit-data.py
More file actions
1710 lines (1454 loc) · 74.4 KB
/
Copy pathinit-data.py
File metadata and controls
1710 lines (1454 loc) · 74.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Complete NYC Taxi Database Initialization
This script handles the complete database setup process:
1. Execute SQL scripts for schema creation
2. Load taxi zone reference data
3. Load complete taxi trip data
4. Verify data integrity
This script runs automatically when PostgreSQL container starts
"""
import os
import time
import glob
import pandas as pd
import numpy as np
import geopandas as gpd
import psycopg2
from sqlalchemy import create_engine, text
from geoalchemy2 import Geometry
from shapely.geometry import MultiPolygon, Polygon
import logging
import requests
from datetime import datetime, timedelta
from urllib.parse import urlparse
from pathlib import Path
import hashlib
import json
# Setup logging
# Create logs directory structure
base_log_dir = '/postgres/logs'
os.makedirs(base_log_dir, exist_ok=True)
# Get backfill configuration for folder structure
backfill_config = os.getenv('BACKFILL_MONTHS', 'default')
if not backfill_config:
backfill_config = 'default'
# Create subdirectory based on BACKFILL_MONTHS
log_subdir = os.path.join(base_log_dir, backfill_config)
os.makedirs(log_subdir, exist_ok=True)
# Generate log filename with execution timestamp
execution_timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
log_filename = f'log_{execution_timestamp}.log'
log_filepath = os.path.join(log_subdir, log_filename)
# Configure dual logging: console + file
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Console handler (existing behavior)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
# File handler (new)
file_handler = logging.FileHandler(log_filepath, mode='w', encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler.setFormatter(file_formatter)
# Add both handlers to logger
logger.addHandler(console_handler)
logger.addHandler(file_handler)
# Initial log entry
logger.info(f"📝 Logging to file: {log_filepath}")
logger.info(f"🕒 Execution started at: {datetime.now().isoformat()}")
logger.info(f"⚙️ Backfill configuration: {backfill_config}")
def wait_for_postgres(host='localhost', port=5432, database='playground',
user='admin', password='admin123', max_attempts=30):
"""Wait for PostgreSQL to be ready"""
attempt = 0
while attempt < max_attempts:
try:
# Try to connect to the database
conn = psycopg2.connect(
host=host, port=port, database=database,
user=user, password=password,
connect_timeout=5
)
# Test that we can actually execute a query
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
conn.close()
logger.info("✅ PostgreSQL is ready!")
return True
except (psycopg2.OperationalError, psycopg2.DatabaseError) as e:
attempt += 1
if attempt <= 10:
# Log more frequently in the beginning
logger.info(f"⏳ Waiting for PostgreSQL... (attempt {attempt}/{max_attempts})")
elif attempt % 5 == 0:
# Then log every 5th attempt
logger.info(f"⏳ Waiting for PostgreSQL... (attempt {attempt}/{max_attempts})")
time.sleep(3)
logger.error("❌ PostgreSQL not available after maximum attempts")
return False
def execute_sql_scripts(engine):
"""Execute SQL initialization scripts in order"""
logger.info("🗃️ Executing SQL initialization scripts...")
# Define the script directory and order
script_dir = '/sql-scripts/init-scripts'
if not os.path.exists(script_dir):
logger.warning(f"⚠️ SQL scripts directory not found: {script_dir}")
return False
# Get all SQL files and sort them (ensuring proper execution order)
sql_files = sorted(glob.glob(os.path.join(script_dir, "*.sql")))
if not sql_files:
logger.warning("⚠️ No SQL scripts found to execute")
return False
logger.info(f"📄 Found {len(sql_files)} SQL scripts to execute")
try:
with engine.connect() as conn:
# Execute each script in order
for script_path in sql_files:
script_name = os.path.basename(script_path)
logger.info(f"🔧 Executing: {script_name}")
# Read the SQL script
with open(script_path, 'r', encoding='utf-8') as f:
sql_content = f.read()
# Clean up the SQL content
sql_content = sql_content.strip()
# Execute the entire script as one block for better compatibility
# This handles complex statements like INSERT...SELECT properly
try:
# Remove comments and empty lines for cleaner execution
clean_sql = '\n'.join([
line for line in sql_content.split('\n')
if line.strip() and not line.strip().startswith('--')
])
if clean_sql:
conn.execute(text(clean_sql))
conn.commit()
except Exception as exec_error:
logger.warning(f"⚠️ Block execution failed for {script_name}, trying statement by statement: {exec_error}")
# Fallback: try to execute statements individually
statements = [stmt.strip() for stmt in sql_content.split(';') if stmt.strip()]
for i, statement in enumerate(statements):
# Skip comments
if statement.startswith('--'):
continue
try:
conn.execute(text(statement))
conn.commit()
except Exception as stmt_error:
logger.error(f"❌ Error in {script_name} statement {i+1}: {stmt_error}")
logger.error(f"Statement was: {statement[:100]}...")
# Continue with other statements
logger.info(f"✅ Completed: {script_name}")
logger.info("✅ All SQL scripts executed successfully!")
return True
except Exception as e:
logger.error(f"❌ Error executing SQL scripts: {str(e)}")
return False
def load_taxi_zones(engine):
"""Load taxi zone reference data"""
logger.info("📍 Loading taxi zone reference data...")
try:
# Clean all dictionary tables (excluding yellow_taxi_trips which contains trip data)
logger.info("🧹 Cleaning dictionary tables for fresh data load...")
with engine.begin() as conn:
# Clean taxi zone tables
conn.execute(text("TRUNCATE TABLE nyc_taxi.taxi_zone_shapes CASCADE"))
conn.execute(text("TRUNCATE TABLE nyc_taxi.taxi_zone_lookup CASCADE"))
# Clean lookup tables
conn.execute(text("TRUNCATE TABLE nyc_taxi.rate_code_lookup CASCADE"))
conn.execute(text("TRUNCATE TABLE nyc_taxi.payment_type_lookup CASCADE"))
conn.execute(text("TRUNCATE TABLE nyc_taxi.vendor_lookup CASCADE"))
logger.info("✅ Dictionary tables cleaned")
# Ensure data directory exists and download all taxi zone data files
data_dir = '/postgres/data'
zones_dir = os.path.join(data_dir, 'zones')
# Always download fresh taxi zone data during initialization
logger.info("📥 Downloading taxi zone reference data...")
if not download_taxi_zone_data(data_dir):
logger.error("💥 Failed to download taxi zone data")
return False
zones_csv_path = os.path.join(zones_dir, 'taxi_zone_lookup.csv')
zones_df = pd.read_csv(zones_csv_path)
zones_df.columns = zones_df.columns.str.lower()
# Handle NULL values - drop rows where zone or borough is NULL
zones_df = zones_df.dropna(subset=['zone', 'borough'])
# Fill remaining NULLs in service_zone with 'Unknown'
zones_df['service_zone'] = zones_df['service_zone'].fillna('Unknown')
# Load to database
zones_df.to_sql(
'taxi_zone_lookup',
engine,
schema='nyc_taxi',
if_exists='append',
index=False
)
logger.info(f"✅ Loaded {len(zones_df)} taxi zones from CSV")
# Load shapefile if available
shapefile_path = os.path.join(zones_dir, 'taxi_zones.shp')
if os.path.exists(shapefile_path):
logger.info("🗺️ Loading taxi zone shapes...")
shapes_gdf = gpd.read_file(shapefile_path)
# Clean and prepare data
shapes_gdf.columns = shapes_gdf.columns.str.lower()
shapes_gdf = shapes_gdf.rename(columns={
'locationid': 'locationid',
'shape_leng': 'shape_leng',
'shape_area': 'shape_area'
})
# Convert to NYC State Plane if needed
if shapes_gdf.crs.to_epsg() != 2263:
logger.info("🔄 Converting CRS to NYC State Plane (EPSG:2263)")
shapes_gdf = shapes_gdf.to_crs(epsg=2263)
# Ensure MultiPolygon geometry
def ensure_multipolygon(geom):
if isinstance(geom, Polygon):
return MultiPolygon([geom])
return geom
shapes_gdf['geometry'] = shapes_gdf['geometry'].apply(ensure_multipolygon)
# Load to database
shapes_gdf.to_postgis(
'taxi_zone_shapes',
engine,
schema='nyc_taxi',
if_exists='append',
index=False
)
logger.info(f"✅ Loaded {len(shapes_gdf)} taxi zone shapes")
else:
logger.warning(f"⚠️ Shapefile not found at {shapefile_path}")
# Reload lookup tables since we cleaned them
logger.info("📚 Reloading lookup tables...")
with engine.begin() as conn:
# Rate code lookup
conn.execute(text("""
INSERT INTO nyc_taxi.rate_code_lookup (ratecodeid, rate_code_desc) VALUES
(1, 'Standard rate'),
(2, 'JFK'),
(3, 'Newark'),
(4, 'Nassau or Westchester'),
(5, 'Negotiated fare'),
(6, 'Group ride')
"""))
# Payment type lookup
conn.execute(text("""
INSERT INTO nyc_taxi.payment_type_lookup (payment_type, payment_type_desc) VALUES
(1, 'Credit card'),
(2, 'Cash'),
(3, 'No charge'),
(4, 'Dispute'),
(5, 'Unknown'),
(6, 'Voided trip')
"""))
# Vendor lookup
conn.execute(text("""
INSERT INTO nyc_taxi.vendor_lookup (vendorid, vendor_name) VALUES
(1, 'Creative Mobile Technologies'),
(2, 'VeriFone Inc.')
"""))
logger.info("✅ Lookup tables reloaded")
# Populate star schema dimensions
logger.info("🌟 Populating star schema dimensions...")
if not populate_star_schema_dimensions(engine):
logger.warning("⚠️ Failed to populate star schema dimensions")
return False
return True
except Exception as e:
logger.error(f"❌ Error loading taxi zones: {str(e)}")
return False
def populate_star_schema_dimensions(engine):
"""Populate star schema dimension tables from normalized data"""
logger.info("🌟 Starting star schema dimension population...")
try:
start_time = time.time()
with engine.begin() as conn:
# Populate location dimension from existing data
logger.info("📍 Populating dim_locations...")
conn.execute(text("""
INSERT INTO nyc_taxi.dim_locations (
locationid, zone, borough, service_zone, zone_type,
is_airport, is_manhattan, is_high_demand, business_district
)
SELECT DISTINCT
tzl.locationid,
tzl.zone,
tzl.borough,
tzl.service_zone,
CASE
WHEN tzl.zone ILIKE '%airport%' OR tzl.service_zone = 'EWR' THEN 'Airport'
WHEN tzl.borough = 'Manhattan' AND tzl.zone ILIKE '%midtown%' THEN 'Manhattan Core'
WHEN tzl.borough = 'Manhattan' THEN 'Manhattan'
ELSE 'Outer Borough'
END as zone_type,
(tzl.zone ILIKE '%airport%' OR tzl.service_zone = 'EWR'),
(tzl.borough = 'Manhattan'),
FALSE, -- Will be updated based on trip volume analysis
(tzl.zone ILIKE '%financial%' OR tzl.zone ILIKE '%midtown%' OR tzl.zone ILIKE '%times square%')
FROM nyc_taxi.taxi_zone_lookup tzl
ON CONFLICT (locationid) DO NOTHING
"""))
# Populate vendor dimension
logger.info("🚗 Populating dim_vendor...")
conn.execute(text("""
INSERT INTO nyc_taxi.dim_vendor (vendorid, vendor_name, vendor_type)
SELECT DISTINCT
vl.vendorid,
vl.vendor_name,
'Technology Provider'
FROM nyc_taxi.vendor_lookup vl
ON CONFLICT (vendorid) DO NOTHING
"""))
# Populate payment type dimension
logger.info("💳 Populating dim_payment_type...")
conn.execute(text("""
INSERT INTO nyc_taxi.dim_payment_type (
payment_type, payment_type_desc, is_electronic, allows_tips
)
SELECT DISTINCT
ptl.payment_type,
ptl.payment_type_desc,
ptl.payment_type NOT IN (2), -- Cash is not electronic
ptl.payment_type IN (1, 3, 4) -- Credit card, No charge, Dispute allow tips
FROM nyc_taxi.payment_type_lookup ptl
ON CONFLICT (payment_type) DO NOTHING
"""))
# Populate rate code dimension
logger.info("📊 Populating dim_rate_code...")
conn.execute(text("""
INSERT INTO nyc_taxi.dim_rate_code (
ratecodeid, rate_code_desc, is_metered, is_airport_rate, is_negotiated
)
SELECT DISTINCT
rcl.ratecodeid,
rcl.rate_code_desc,
rcl.ratecodeid IN (1), -- Only standard rate is metered
rcl.ratecodeid IN (2, 3), -- JFK, Newark are airport rates
rcl.ratecodeid IN (5, 6) -- Negotiated and group ride
FROM nyc_taxi.rate_code_lookup rcl
ON CONFLICT (ratecodeid) DO NOTHING
"""))
# Record quality metrics for dimension population while connection is still active
processing_time_ms = int((time.time() - start_time) * 1000)
# Record metrics for each dimension table using the active connection
for table_name in ['dim_locations', 'dim_vendor', 'dim_payment_type', 'dim_rate_code']:
try:
result = conn.execute(text(f"SELECT COUNT(*) FROM nyc_taxi.{table_name}"))
row_count = result.fetchone()[0]
record_quality_metrics(engine, {
'operation_type': 'dimension_load',
'target_table': table_name,
'rows_attempted': row_count, # For dimensions, attempted = inserted since we use ON CONFLICT
'rows_inserted': row_count,
'rows_duplicates': 0,
'rows_invalid': 0,
'processing_duration_ms': processing_time_ms // 4, # Divide by number of tables
'batch_id': 'dimension_initialization'
})
except Exception as metric_error:
logger.warning(f"⚠️ Could not record metrics for {table_name}: {str(metric_error)}")
logger.info("✅ Star schema dimensions populated successfully!")
return True
except Exception as e:
logger.error(f"❌ Error populating star schema dimensions: {str(e)}")
# Record failure metrics
processing_time_ms = int((time.time() - start_time) * 1000)
record_quality_metrics(engine, {
'operation_type': 'dimension_load_failed',
'target_table': 'all_dimensions',
'rows_attempted': 0,
'rows_inserted': 0,
'rows_duplicates': 0,
'rows_invalid': 0,
'processing_duration_ms': processing_time_ms,
'error_message_sample': str(e)[:200],
'batch_id': 'dimension_initialization'
})
return False
def download_taxi_zone_data(data_dir='/postgres/data'):
"""Download taxi zone reference data (CSV and shapefiles)"""
logger.info("📥 Downloading taxi zone reference data...")
# Ensure zones subdirectory exists
zones_dir = os.path.join(data_dir, 'zones')
os.makedirs(zones_dir, exist_ok=True)
# Download taxi zone lookup CSV
csv_url = "https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv"
csv_filename = "taxi_zone_lookup.csv"
csv_path = os.path.join(zones_dir, csv_filename)
# Always download fresh CSV during initialization
logger.info(f"📥 Downloading {csv_filename}...")
try:
response = requests.get(csv_url)
response.raise_for_status()
with open(csv_path, 'wb') as f:
f.write(response.content)
logger.info(f"✅ Downloaded: {csv_filename}")
except Exception as e:
logger.error(f"❌ Failed to download {csv_filename}: {str(e)}")
return False
# Download taxi zone shapefile (ZIP format)
zip_url = "https://d37ci6vzurychx.cloudfront.net/misc/taxi_zones.zip"
zip_filename = "taxi_zones.zip"
zip_path = os.path.join(zones_dir, zip_filename)
# Always download fresh shapefile ZIP during initialization
logger.info(f"📥 Downloading {zip_filename}...")
try:
response = requests.get(zip_url)
response.raise_for_status()
with open(zip_path, 'wb') as f:
f.write(response.content)
logger.info(f"✅ Downloaded: {zip_filename}")
# Extract the ZIP file
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(zones_dir)
logger.info("✅ Extracted shapefile components")
# Clean up the ZIP file
os.remove(zip_path)
logger.info("🗑️ Cleaned up ZIP file")
except Exception as e:
logger.error(f"❌ Failed to download or extract {zip_filename}: {str(e)}")
return False
# Verify shapefile components exist
required_files = ['taxi_zones.shp', 'taxi_zones.dbf', 'taxi_zones.shx', 'taxi_zones.prj']
for filename in required_files:
file_path = os.path.join(zones_dir, filename)
if not os.path.exists(file_path):
logger.warning(f"⚠️ Missing shapefile component: {filename}")
logger.info("✅ All taxi zone reference data files are available")
return True
def download_taxi_data(year, month, data_dir='/postgres/data'):
"""Download taxi data for a specific year and month"""
# Ensure yellow subdirectory exists
yellow_dir = os.path.join(data_dir, 'yellow')
os.makedirs(yellow_dir, exist_ok=True)
url = f"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_{year:04d}-{month:02d}.parquet"
filename = f"yellow_tripdata_{year:04d}-{month:02d}.parquet"
file_path = os.path.join(yellow_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
logger.info(f"📂 File already exists: {filename}")
return file_path
logger.info(f"📥 Downloading {filename} from NYC TLC...")
try:
response = requests.get(url, stream=True)
response.raise_for_status()
# Get file size for progress tracking
total_size = int(response.headers.get('content-length', 0))
with open(file_path, 'wb') as f:
downloaded = 0
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
progress = (downloaded / total_size) * 100
if downloaded % (1024 * 1024 * 10) == 0: # Log every 10MB
logger.info(f"📥 Progress: {progress:.1f}% ({downloaded:,}/{total_size:,} bytes)")
logger.info(f"✅ Downloaded: {filename} ({os.path.getsize(file_path):,} bytes)")
return file_path
except requests.exceptions.RequestException as e:
logger.error(f"❌ Failed to download {filename}: {str(e)}")
return None
except Exception as e:
logger.error(f"❌ Error downloading {filename}: {str(e)}")
return None
def check_month_already_processed(engine, year, month):
"""Check if a specific month has already been processed"""
try:
with engine.connect() as conn:
result = conn.execute(text(
"SELECT status, records_loaded, processing_completed_at FROM nyc_taxi.data_processing_log WHERE data_year = :year AND data_month = :month"
), {"year": year, "month": month})
row = result.fetchone()
if row:
status, records_loaded, completed_at = row
if status == 'completed':
logger.info(f"🔄 {year}-{month:02d}: Already processed ({records_loaded:,} records at {completed_at})")
return True
elif status == 'in_progress':
logger.warning(f"⚠️ {year}-{month:02d}: Previous processing incomplete, will retry")
# Delete incomplete record to allow retry
conn.execute(text(
"DELETE FROM nyc_taxi.data_processing_log WHERE data_year = :year AND data_month = :month"
), {"year": year, "month": month})
conn.commit()
elif status == 'failed':
logger.info(f"🔄 {year}-{month:02d}: Previous attempt failed, will retry")
# Delete failed record to allow retry
conn.execute(text(
"DELETE FROM nyc_taxi.data_processing_log WHERE data_year = :year AND data_month = :month"
), {"year": year, "month": month})
conn.commit()
return False
except Exception as e:
logger.error(f"❌ Error checking processing status for {year}-{month:02d}: {str(e)}")
return False
def start_processing_log(engine, year, month, filename, backfill_config):
"""Start processing log entry for a month"""
try:
with engine.connect() as conn:
conn.execute(text("""
INSERT INTO nyc_taxi.data_processing_log
(data_year, data_month, file_name, backfill_config, status)
VALUES (:year, :month, :filename, :config, 'in_progress')
"""), {
"year": year,
"month": month,
"filename": filename,
"config": backfill_config
})
conn.commit()
logger.info(f"📝 Started processing log for {year}-{month:02d}")
except Exception as e:
logger.error(f"❌ Error starting processing log for {year}-{month:02d}: {str(e)}")
def complete_processing_log(engine, year, month, records_loaded):
"""Complete processing log entry for a month"""
try:
with engine.connect() as conn:
conn.execute(text("""
UPDATE nyc_taxi.data_processing_log
SET status = 'completed',
records_loaded = :records,
processing_completed_at = CURRENT_TIMESTAMP
WHERE data_year = :year AND data_month = :month
"""), {
"year": year,
"month": month,
"records": records_loaded
})
conn.commit()
logger.info(f"✅ Completed processing log for {year}-{month:02d}: {records_loaded:,} records")
except Exception as e:
logger.error(f"❌ Error completing processing log for {year}-{month:02d}: {str(e)}")
def fail_processing_log(engine, year, month):
"""Mark processing log entry as failed for a month"""
try:
with engine.connect() as conn:
conn.execute(text("""
UPDATE nyc_taxi.data_processing_log
SET status = 'failed'
WHERE data_year = :year AND data_month = :month
"""), {"year": year, "month": month})
conn.commit()
logger.info(f"❌ Marked processing as failed for {year}-{month:02d}")
except Exception as e:
logger.error(f"❌ Error marking processing as failed for {year}-{month:02d}: {str(e)}")
def calculate_row_hash(row):
"""Calculate SHA-256 hash of all row values for duplicate detection - Optimized version"""
try:
# Pre-allocate list for better performance than string concatenation
hash_parts = []
# Process columns in deterministic order (sorted by column name)
for column in sorted(row.index):
value = row[column]
# Fast string conversion without intermediate dict
if pd.isna(value) or value is None:
hash_parts.append("")
elif isinstance(value, (int, float)):
# Reduced precision for better performance, still unique enough
hash_parts.append(f"{value:.6f}" if isinstance(value, float) else str(value))
elif isinstance(value, pd.Timestamp):
# Use faster strftime instead of isoformat
hash_parts.append(value.strftime('%Y-%m-%d %H:%M:%S') if not pd.isna(value) else "")
else:
hash_parts.append(str(value))
# Direct string join - much faster than JSON serialization
row_string = '|'.join(hash_parts)
# Calculate SHA-256 hash
return hashlib.sha256(row_string.encode('utf-8')).hexdigest()
except Exception as e:
# Simplified fallback with better performance
logger.warning(f"⚠️ Hash calculation fallback for row: {str(e)}")
fallback_string = '|'.join([str(v) if not pd.isna(v) else '' for v in row.values])
return hashlib.sha256(fallback_string.encode('utf-8')).hexdigest()
def add_row_hash_column(df):
"""Add row_hash column to dataframe"""
logger.info("🔐 Calculating row hashes for duplicate prevention...")
# Calculate hash for each row
df['row_hash'] = df.apply(calculate_row_hash, axis=1)
# Check for hash collisions within the dataframe (should be extremely rare)
hash_counts = df['row_hash'].value_counts()
collisions = hash_counts[hash_counts > 1]
if len(collisions) > 0:
logger.warning(f"⚠️ Found {len(collisions)} hash collisions within batch - removing duplicates")
# Keep first occurrence of each hash
df = df.drop_duplicates(subset=['row_hash'], keep='first')
logger.info(f"✅ Generated {len(df):,} unique row hashes")
return df
def get_backfill_months(backfill_config):
"""Parse backfill configuration and return list of (year, month) tuples"""
months = []
if backfill_config == 'all':
# Load all available data from 2020 to current month
current_date = datetime.now()
for year in range(2020, current_date.year + 1):
end_month = current_date.month if year == current_date.year else 12
for month in range(1, end_month + 1):
months.append((year, month))
elif backfill_config.startswith('last_'):
# Parse formats like 'last_6_months', 'last_12_months'
try:
num_months = int(backfill_config.split('_')[1])
current_date = datetime.now()
for i in range(num_months):
date = current_date - timedelta(days=30 * i) # Approximate month
months.append((date.year, date.month))
except (IndexError, ValueError):
logger.error(f"❌ Invalid backfill format: {backfill_config}")
elif ',' in backfill_config:
# Parse comma-separated list like '2024-01,2024-02,2024-03'
for month_str in backfill_config.split(','):
try:
year, month = month_str.strip().split('-')
months.append((int(year), int(month)))
except ValueError:
logger.error(f"❌ Invalid month format: {month_str}")
else:
# Parse single month like '2024-01'
try:
year, month = backfill_config.split('-')
months.append((int(year), int(month)))
except ValueError:
logger.error(f"❌ Invalid backfill format: {backfill_config}")
# Remove duplicates and sort
months = sorted(list(set(months)))
logger.info(f"📅 Backfill months: {len(months)} months from {months[0] if months else 'none'} to {months[-1] if months else 'none'}")
return months
def load_single_parquet_file(engine, file_path, chunk_size=10000):
"""Load a single parquet file into both normalized and star schema tables with invalid row handling"""
filename = os.path.basename(file_path)
try:
logger.info(f"📥 Loading {filename}...")
# Read parquet file
df = pd.read_parquet(file_path)
logger.info(f"📊 File contains {len(df):,} rows")
# Convert column names to lowercase to match database schema
df.columns = df.columns.str.lower()
# Clean and prepare data
df['tpep_pickup_datetime'] = pd.to_datetime(df['tpep_pickup_datetime'])
df['tpep_dropoff_datetime'] = pd.to_datetime(df['tpep_dropoff_datetime'])
# Keep all historical data - don't filter by year
# Fill nulls
numeric_cols = ['passenger_count', 'trip_distance', 'ratecodeid', 'fare_amount',
'extra', 'mta_tax', 'tip_amount', 'tolls_amount', 'improvement_surcharge',
'total_amount', 'congestion_surcharge', 'airport_fee', 'cbd_congestion_fee']
for col in numeric_cols:
if col in df.columns:
df[col] = df[col].fillna(0)
else:
# Add missing columns with 0 values (e.g., cbd_congestion_fee not in older data)
df[col] = 0
df['store_and_fwd_flag'] = df['store_and_fwd_flag'].fillna('N')
# Hash generation moved to per-chunk processing for better performance and duplicate detection
# Load in chunks with error handling for invalid rows
total_chunks = len(df) // chunk_size + (1 if len(df) % chunk_size else 0)
loaded_rows = 0
star_loaded_rows = 0
duplicate_rows = 0
invalid_rows = 0
for i, chunk_start in enumerate(range(0, len(df), chunk_size)):
chunk_end = min(chunk_start + chunk_size, len(df))
chunk_df = df.iloc[chunk_start:chunk_end]
# Process chunk with individual row error handling
chunk_results = load_chunk_with_error_handling(engine, chunk_df, filename, i + 1)
loaded_rows += chunk_results['loaded']
star_loaded_rows += chunk_results['star_loaded']
duplicate_rows += chunk_results['duplicates']
invalid_rows += chunk_results['invalid']
if (i + 1) % 5 == 0 or i == total_chunks - 1:
logger.info(f"📥 {filename}: Processed {chunk_end:,} rows - Loaded: {loaded_rows:,} normalized, {star_loaded_rows:,} star, {duplicate_rows:,} duplicates, {invalid_rows:,} invalid ({i+1}/{total_chunks} chunks)")
# Final summary
total_processed = loaded_rows + duplicate_rows + invalid_rows
logger.info(f"✅ {filename}: Completed - {loaded_rows:,} loaded to normalized, {star_loaded_rows:,} to star schema, {duplicate_rows:,} duplicates, {invalid_rows:,} invalid rows")
return loaded_rows
except Exception as e:
logger.error(f"❌ Error loading {filename}: {str(e)}")
return 0
def load_chunk_with_error_handling(engine, chunk_df, filename, chunk_number):
"""Load a chunk with individual row error handling and quality monitoring"""
results = {'loaded': 0, 'star_loaded': 0, 'duplicates': 0, 'invalid': 0}
start_time = time.time()
try:
# Add hash generation for this chunk
chunk_df = add_row_hash_column(chunk_df)
# First attempt: try to load the entire chunk
chunk_df.to_sql(
'yellow_taxi_trips',
engine,
schema='nyc_taxi',
if_exists='append',
index=False,
method='multi'
)
results['loaded'] = len(chunk_df)
# Load to star schema
star_rows = load_chunk_to_star_schema(engine, chunk_df, filename, chunk_number)
results['star_loaded'] = star_rows
# Record successful quality metrics
processing_time_ms = int((time.time() - start_time) * 1000)
record_quality_metrics(engine, {
'source_file': filename,
'operation_type': 'chunk_insert',
'target_table': 'yellow_taxi_trips',
'chunk_number': chunk_number,
'rows_attempted': len(chunk_df),
'rows_inserted': results['loaded'],
'rows_duplicates': 0,
'rows_invalid': 0,
'processing_duration_ms': processing_time_ms,
'min_date_value': chunk_df['tpep_pickup_datetime'].min().date() if not chunk_df['tpep_pickup_datetime'].isna().all() else None,
'max_date_value': chunk_df['tpep_pickup_datetime'].max().date() if not chunk_df['tpep_pickup_datetime'].isna().all() else None,
'avg_numeric_value': float(chunk_df['total_amount'].mean()) if 'total_amount' in chunk_df.columns and not chunk_df['total_amount'].isna().all() else None
})
# Record star schema quality metrics
if star_rows > 0:
record_quality_metrics(engine, {
'source_file': filename,
'operation_type': 'star_schema_insert',
'target_table': 'fact_taxi_trips',
'chunk_number': chunk_number,
'rows_attempted': len(chunk_df),
'rows_inserted': star_rows,
'rows_duplicates': 0,
'rows_invalid': 0,
'processing_duration_ms': processing_time_ms
})
return results
except Exception as chunk_error:
error_str = str(chunk_error).lower()
# Check if it's a known duplicate error type
if any(keyword in error_str for keyword in ['unique constraint', 'duplicate key', 'row_hash']):
results['duplicates'] = len(chunk_df)
# Record duplicate quality metrics
processing_time_ms = int((time.time() - start_time) * 1000)
record_quality_metrics(engine, {
'source_file': filename,
'operation_type': 'chunk_insert',
'target_table': 'yellow_taxi_trips',
'chunk_number': chunk_number,
'rows_attempted': len(chunk_df),
'rows_inserted': 0,
'rows_duplicates': len(chunk_df),
'rows_invalid': 0,
'processing_duration_ms': processing_time_ms,
'primary_error_types': ['duplicate_key'],
'error_message_sample': str(chunk_error)[:200]
})
return results
# For other errors, process row by row to identify invalid rows
logger.warning(f"⚠️ Chunk {chunk_number} bulk insert failed, processing row by row: {str(chunk_error)[:100]}")
for row_idx, (_, row) in enumerate(chunk_df.iterrows()):
try:
# Try to insert single row
single_row_df = chunk_df.iloc[row_idx:row_idx+1]
single_row_df.to_sql(
'yellow_taxi_trips',
engine,
schema='nyc_taxi',
if_exists='append',
index=False,
method='multi'
)
results['loaded'] += 1
# Load to star schema
star_rows = load_chunk_to_star_schema(engine, single_row_df, filename, chunk_number)
results['star_loaded'] += star_rows
except Exception as row_error:
# Classify the error and store invalid row
error_str = str(row_error).lower()
if any(keyword in error_str for keyword in ['unique constraint', 'duplicate key', 'row_hash']):
results['duplicates'] += 1
error_type = 'duplicate_key'
elif 'primary key' in error_str:
results['invalid'] += 1
error_type = 'primary_key_violation'
elif 'check constraint' in error_str:
results['invalid'] += 1
error_type = 'constraint_violation'
elif 'data type' in error_str or 'invalid input' in error_str:
results['invalid'] += 1
error_type = 'data_type_error'
else:
results['invalid'] += 1
error_type = 'unknown_error'
# Store invalid row if it's not a duplicate
if error_type != 'duplicate_key':
store_invalid_row(engine, row, filename, chunk_number, row_idx + 1, str(row_error), error_type)
# Record quality metrics for row-by-row processing
processing_time_ms = int((time.time() - start_time) * 1000)
error_types = []
if results['duplicates'] > 0:
error_types.append('duplicate_key')
if results['invalid'] > 0:
error_types.append('validation_error')
record_quality_metrics(engine, {
'source_file': filename,
'operation_type': 'chunk_insert_with_errors',
'target_table': 'yellow_taxi_trips',
'chunk_number': chunk_number,
'rows_attempted': len(chunk_df),
'rows_inserted': results['loaded'],
'rows_duplicates': results['duplicates'],
'rows_invalid': results['invalid'],
'processing_duration_ms': processing_time_ms,
'primary_error_types': error_types,
'error_message_sample': str(chunk_error)[:200] if 'chunk_error' in locals() else None,
'constraint_violations': results['invalid'],
'min_date_value': chunk_df['tpep_pickup_datetime'].min().date() if not chunk_df['tpep_pickup_datetime'].isna().all() else None,
'max_date_value': chunk_df['tpep_pickup_datetime'].max().date() if not chunk_df['tpep_pickup_datetime'].isna().all() else None
})
return results
def record_quality_metrics(engine, metrics):
"""Record data quality metrics for a chunk operation"""
try:
with engine.begin() as conn:
# Convert lists to JSON for primary_error_types
primary_error_types = metrics.get('primary_error_types', [])
if isinstance(primary_error_types, list):
primary_error_types = json.dumps(primary_error_types)
conn.execute(text("""
INSERT INTO nyc_taxi.data_quality_monitor (
source_file, operation_type, target_table, chunk_number,
rows_attempted, rows_inserted, rows_duplicates, rows_invalid,
processing_duration_ms, primary_error_types, error_message_sample,
constraint_violations, min_date_value, max_date_value, avg_numeric_value,
data_hash, batch_id
) VALUES (
:source_file, :operation_type, :target_table, :chunk_number,
:rows_attempted, :rows_inserted, :rows_duplicates, :rows_invalid,
:processing_duration_ms, :primary_error_types, :error_message_sample,
:constraint_violations, :min_date_value, :max_date_value, :avg_numeric_value,
:data_hash, :batch_id
)
"""), {
'source_file': metrics.get('source_file'),
'operation_type': metrics.get('operation_type'),
'target_table': metrics.get('target_table'),
'chunk_number': metrics.get('chunk_number'),
'rows_attempted': metrics.get('rows_attempted', 0),
'rows_inserted': metrics.get('rows_inserted', 0),
'rows_duplicates': metrics.get('rows_duplicates', 0),
'rows_invalid': metrics.get('rows_invalid', 0),
'processing_duration_ms': metrics.get('processing_duration_ms'),
'primary_error_types': primary_error_types,
'error_message_sample': metrics.get('error_message_sample'),
'constraint_violations': metrics.get('constraint_violations', 0),
'min_date_value': metrics.get('min_date_value'),
'max_date_value': metrics.get('max_date_value'),
'avg_numeric_value': metrics.get('avg_numeric_value'),
'data_hash': metrics.get('data_hash'),
'batch_id': metrics.get('batch_id', f"batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
})
except Exception as e:
logger.warning(f"⚠️ Failed to record quality metrics: {str(e)}")
def store_invalid_row(engine, row, source_file, chunk_number, row_number_in_chunk, error_message, error_type):
"""Store an invalid row in the yellow_taxi_trips_invalid table"""
try:
with engine.begin() as conn:
# Convert row to dictionary and handle NaN values and numpy types
row_dict = {}
for col, value in row.items():
if pd.isna(value):
row_dict[col] = None
else:
# Convert numpy types to Python native types for psycopg2 compatibility