openfoodfacts_ingestion.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. from airflow import DAG
  2. from airflow.operators.python import PythonOperator
  3. from airflow.providers.docker.operators.docker import DockerOperator
  4. from airflow.exceptions import AirflowSkipException
  5. from datetime import datetime, timedelta
  6. import os
  7. import hashlib
  8. import requests
  9. import urllib.request
  10. DATA_DIR = "/opt/airflow/data"
  11. INGEST_FILE = "en.openfoodfacts.org.products.csv"
  12. URL = "https://static.openfoodfacts.org/data/en.openfoodfacts.org.products.csv"
  13. default_args = {
  14. 'owner': 'airflow',
  15. 'depends_on_past': False,
  16. 'start_date': datetime(2026, 1, 1),
  17. 'email_on_failure': False,
  18. 'email_on_retry': False,
  19. 'retries': 1,
  20. 'retry_delay': timedelta(minutes=5),
  21. }
  22. dag = DAG(
  23. 'openfoodfacts_ingestion',
  24. default_args=default_args,
  25. description='Automated Data Freshness Pipeline for OpenFoodFacts',
  26. schedule_interval='0 4 * * *', # Daily at 04:00
  27. catchup=False,
  28. )
  29. def download_and_validate(**kwargs):
  30. os.makedirs(DATA_DIR, exist_ok=True)
  31. file_path = os.path.join(DATA_DIR, INGEST_FILE)
  32. print("Downloading dataset...")
  33. # Downloading stream to handle large files
  34. try:
  35. urllib.request.urlretrieve(URL, file_path)
  36. except Exception as e:
  37. print(f"Failed to download: {e}")
  38. raise
  39. print("Calculating checksum...")
  40. md5_hash = hashlib.md5()
  41. with open(file_path, "rb") as f:
  42. for byte_block in iter(lambda: f.read(4096), b""):
  43. md5_hash.update(byte_block)
  44. new_checksum = md5_hash.hexdigest()
  45. checksum_file = os.path.join(DATA_DIR, "checksum.md5")
  46. old_checksum = ""
  47. if os.path.exists(checksum_file):
  48. with open(checksum_file, "r") as f:
  49. old_checksum = f.read().strip()
  50. if new_checksum == old_checksum:
  51. print("Checksum matches previously processed file. Skipping ingestion.")
  52. raise AirflowSkipException("Dataset is already up to date.")
  53. print("Checksum mismatch: File is new or modified. Ingestion required.")
  54. # Push new checksum to XCom so the next task can save it upon success
  55. kwargs['ti'].xcom_push(key='new_checksum', value=new_checksum)
  56. return True
  57. def save_checksum(**kwargs):
  58. new_checksum = kwargs['ti'].xcom_pull(key='new_checksum', task_ids='validate_freshness')
  59. checksum_file = os.path.join(DATA_DIR, "checksum.md5")
  60. with open(checksum_file, "w") as f:
  61. f.write(new_checksum)
  62. print("Checksum saved successfully.")
  63. t1_validate = PythonOperator(
  64. task_id='validate_freshness',
  65. python_callable=download_and_validate,
  66. provide_context=True,
  67. dag=dag,
  68. )
  69. # DockerOperator requires the docker socket to be mounted to the airflow container
  70. # It will spawn a container using the same image as our ingest service
  71. t2_ingest = DockerOperator(
  72. task_id='trigger_ingestion_container',
  73. image='food_project-ingest',
  74. api_version='auto',
  75. auto_remove=True,
  76. command='./ingest_csv.py /data/en.openfoodfacts.org.products.csv',
  77. docker_url='unix://var/run/docker.sock',
  78. network_mode='food_project_default',
  79. # We must mount the local data dir into the ingest container so it can see the CSV
  80. # We use the relative host path since the docker socket resolves from the host's perspective!
  81. # Airflow runs in Docker, but the socket is the Host's socket.
  82. mounts=[
  83. # Host path -> Container path
  84. # Assuming the host project is in /home/francois/food_project
  85. # Note: This hardcoding is necessary when triggering sibling containers via socket
  86. # unless using complex volume bindings.
  87. ],
  88. environment={
  89. 'DB_HOST': 'mysql',
  90. 'DB_USER': 'food_loader',
  91. 'DB_PASS': 'BTSai123'
  92. },
  93. mount_tmp_dir=False,
  94. dag=dag,
  95. )
  96. # Because host paths can vary, it's safer to use the named volume or rely on the fact
  97. # that docker-compose already created the image.
  98. # Wait, the ingest image COPY . /app. So the script is already inside.
  99. # But the CSV is in the host's ./data directory.
  100. # To fix the host path mount dynamically without hardcoding /home/francois/food_project:
  101. # The DockerOperator can mount volumes like this: "food_project_data:/data" but we don't have a named volume for data.
  102. # Let's map it via volumes argument.
  103. t2_ingest.volumes = ['/home/francois/food_project/data:/data']
  104. t3_save_checksum = PythonOperator(
  105. task_id='save_checksum',
  106. python_callable=save_checksum,
  107. provide_context=True,
  108. dag=dag,
  109. )
  110. t1_validate >> t2_ingest >> t3_save_checksum