<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Dogukan Ulu on Medium]]></title>
        <description><![CDATA[Stories by Dogukan Ulu on Medium]]></description>
        <link>https://medium.com/@dogukannulu?source=rss-717a86445022------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*wpIYE7HbXzSCnjUU0wwv7w.jpeg</url>
            <title>Stories by Dogukan Ulu on Medium</title>
            <link>https://medium.com/@dogukannulu?source=rss-717a86445022------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 16:32:02 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@dogukannulu/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Data Engineering End-to-End Project — Airflow, Kafka, MySQL, Metabase, Docker]]></title>
            <link>https://medium.com/@dogukannulu/data-engineering-end-to-end-project-airflow-kafka-mysql-metabase-docker-27c805f56535?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/27c805f56535</guid>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[kafka]]></category>
            <category><![CDATA[airflow]]></category>
            <category><![CDATA[metabase]]></category>
            <category><![CDATA[mysql]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Thu, 30 Jul 2026 17:22:37 GMT</pubDate>
            <atom:updated>2026-07-30T17:22:37.253Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Fll75fCDg9Xkk_r0w-66fw.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming">GitHub - dogukannulu/crypto_api_kafka_airflow_streaming: Get Crypto data from API, stream it to Kafka with Airflow. Write data to MySQL and visualize with Metabase</a></p><h3>Tech Stack</h3><ol><li>Apache Airflow</li><li>Apache Kafka</li><li>Apache Zookeeper</li><li>Python</li><li>SQL</li><li>API</li><li>MySQL</li><li>Metabase</li><li>Docker</li></ol><h3>Overview</h3><p>We will be illustrating a streaming data pipeline with this project.</p><p><a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/scripts/crypto_data_stream.py">crypto_data_stream.py</a> gets BTC prices from the <a href="https://coinmarketcap.com/api/">Crypto API</a> (provided by CoinMarketCap) with an API key. The same script also sends the price-related data to the Kafka topic (producer) every 10 seconds using Airflow for a time period of 3 minutes. We are going to schedule the jobs using Airflow DAGs with <a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/dags/crypto_data_stream_dag.py">crypto_data_stream_dag.py</a>. Once the data is written to Kafka, we will get the data from the Kafka topic (consumer) with <a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/scripts/read_kafka_write_mysql.py">read_kafka_write_mysql.py</a>. Once the data is written to the MySQL table, we will connect Metabase to MySQL and create a real-time dashboard in the end.</p><p>All services will be running as <a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/docker-compose.yaml">docker containers</a>. I have connected all containers with each other so that we will be able to write data to Kafka and connect Metabase to MySQL.</p><h3>Apache Airflow</h3><p>The first thing we have to do is run all the services as Docker containers. For this, we will use the official Airflow image. We have to first install all the necessary libraries and packages into the Airflow container. For that, we have to create a Dockerfile.</p><pre># Use the Apache Airflow 2.7.1 image as the base image<br>FROM apache/airflow:2.7.1<br># Switch to the &quot;airflow&quot; user<br>USER airflow<br># Install pip<br>RUN curl -O &#39;https://bootstrap.pypa.io/get-pip.py&#39; &amp;&amp; \<br>    python3 get-pip.py --user<br># Install libraries from requirements.txt<br>COPY requirements.txt /requirements.txt<br>RUN pip install --user -r /requirements.txt</pre><p>This Dockerfile will be used to install the <strong>pip</strong> command first. Then, it will install all the necessary libraries in the <a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/requirements.txt"><strong>requirements.txt</strong></a> file so that we won’t get an import error in the future. We will use this Dockerfile to build a container <strong>install-requirements</strong>.</p><p>After creating the Dockerfile, we can follow the instructions that <a href="https://airflow.apache.org/docs/apache-airflow/stable/howto/docker-compose/index.html">this link</a> provides us. After completing all the instructions, we will have a <strong>docker-compose.yaml</strong> file locally.<br>After obtaining the docker-compose file, we are going to modify that before starting the services. The first thing we have to do is add the following container under the services section.</p><pre>install-requirements:<br>    &lt;&lt;: *airflow-common<br>    container_name: install-requirements<br>    build:<br>      context: .<br>    volumes:<br>      - ./requirements.txt:/requirements.txt<br>    depends_on:<br>      - postgres<br>      - redis<br>    networks:<br>      - all-network</pre><p>This will be the container that will install all the necessary dependencies inside the Airflow container.</p><h3>Apache Kafka</h3><p><a href="https://github.com/dogukannulu/crypto_api_kafka_airflow_streaming/blob/main/docker-compose.yaml">docker-compose.yaml</a> will also create a multi-node (3) Kafka cluster. We have to add the following services to the docker-compose file that we obtained from the official Airflow page.</p><pre>  zoo1:<br>    image: confluentinc/cp-zookeeper:7.3.2<br>    ports:<br>      - &quot;2181:2181&quot;<br>    environment:<br>      ZOOKEEPER_CLIENT_PORT: 2181<br>      ZOOKEEPER_SERVER_ID: 1<br>      ZOOKEEPER_SERVERS: zoo1:2888:3888<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  kafka1:<br>    image: confluentinc/cp-kafka:7.3.2<br>    ports:<br>      - &quot;9092:9092&quot;<br>      - &quot;29092:29092&quot;<br>    environment:<br>      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092<br>      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT<br>      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL<br>      KAFKA_ZOOKEEPER_CONNECT: &quot;zoo1:2181&quot;<br>      KAFKA_BROKER_ID: 1<br>      KAFKA_LOG4J_LOGGERS: &quot;kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO&quot;<br>      KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer<br>      KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: &quot;true&quot;<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  kafka2:<br>    image: confluentinc/cp-kafka:7.3.2<br>    ports:<br>      - &quot;9093:9093&quot;<br>      - &quot;29093:29093&quot;<br>    environment:<br>      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:19093,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9093,DOCKER://host.docker.internal:29093<br>      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT<br>      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL<br>      KAFKA_ZOOKEEPER_CONNECT: &quot;zoo1:2181&quot;<br>      KAFKA_BROKER_ID: 2<br>      KAFKA_LOG4J_LOGGERS: &quot;kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO&quot;<br>      KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer<br>      KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: &quot;true&quot;<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  kafka3:<br>    image: confluentinc/cp-kafka:7.3.2<br>    ports:<br>      - &quot;9094:9094&quot;<br>      - &quot;29094:29094&quot;<br>    environment:<br>      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:19094,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9094,DOCKER://host.docker.internal:29094<br>      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT<br>      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL<br>      KAFKA_ZOOKEEPER_CONNECT: &quot;zoo1:2181&quot;<br>      KAFKA_BROKER_ID: 3<br>      KAFKA_LOG4J_LOGGERS: &quot;kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO&quot;<br>      KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer<br>      KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: &quot;true&quot;<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  kafka-connect:<br>    image: confluentinc/cp-kafka-connect:7.3.2<br>    ports:<br>      - &quot;8083:8083&quot;<br>    environment:<br>      CONNECT_BOOTSTRAP_SERVERS: kafka1:19092,kafka2:19093,kafka3:19094<br>      CONNECT_REST_PORT: 8083<br>      CONNECT_GROUP_ID: compose-connect-group<br>      CONNECT_CONFIG_STORAGE_TOPIC: compose-connect-configs<br>      CONNECT_OFFSET_STORAGE_TOPIC: compose-connect-offsets<br>      CONNECT_STATUS_STORAGE_TOPIC: compose-connect-status<br>      CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter<br>      CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.storage.StringConverter<br>      CONNECT_INTERNAL_KEY_CONVERTER: &quot;org.apache.kafka.connect.json.JsonConverter&quot;<br>      CONNECT_INTERNAL_VALUE_CONVERTER: &quot;org.apache.kafka.connect.json.JsonConverter&quot;<br>      CONNECT_REST_ADVERTISED_HOST_NAME: &#39;kafka-connect&#39;<br>      CONNECT_LOG4J_ROOT_LOGLEVEL: &#39;INFO&#39;<br>      CONNECT_LOG4J_LOGGERS: &#39;org.apache.kafka.connect.runtime.rest=WARN,org.reflections=ERROR&#39;<br>      CONNECT_PLUGIN_PATH: &#39;/usr/share/java,/usr/share/confluent-hub-components&#39;<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  schema-registry:<br>    image: confluentinc/cp-schema-registry:7.3.2<br>    ports:<br>      - &quot;8081:8081&quot;<br>    environment:<br>      SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka1:19092,kafka2:19093,kafka3:19094<br>      SCHEMA_REGISTRY_HOST_NAME: schema-registry<br>      SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081<br>    networks:<br>      - kafka-network<br>      - all-network<br><br>  kafka-ui:<br>    container_name: kafka-ui<br>    image: provectuslabs/kafka-ui:latest<br>    ports:<br>      - 8888:8080<br>    depends_on:<br>      - kafka1<br>      - kafka2<br>      - kafka3<br>      - schema-registry<br>      - kafka-connect<br>    environment:<br>      KAFKA_CLUSTERS_0_NAME: local<br>      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: PLAINTEXT://kafka1:19092,PLAINTEXT_HOST://kafka1:19092<br>      KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://schema-registry:8081<br>      KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: connect<br>      KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://kafka-connect:8083<br>      DYNAMIC_CONFIG_ENABLED: &#39;true&#39;<br>    networks:<br>      - kafka-network<br>      - all-network</pre><p>Once we run the container, we can wait a bit and access the Kafka UI via <a href="http://localhost:8888/">localhost:8888</a>. We can define the replication factor as 3 since there are 3 nodes (kafka1, kafka2, kafka3) while creating a new Kafka topic. We should only run:</p><pre>docker-compose up -d --build</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*wEmDWqT9UeLNrb1c.png" /></figure><p>After accessing Kafka UI, we can create the topic btc_prices. Then, we can see the messages coming to the Kafka topic. All these are going to happen after running the whole project (after running the DAGs for example).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/374/0*MvL5hjzmqU7P1Bhe.png" /></figure><p>Even though the first Python script will be running as a task of the Airflow DAG in the end, I would like to introduce the script now. We should retrieve data from the Crypto API and write the data to the Kafka topic.</p><p>First of all, we have to import the necessary libraries and define the logger so that we will keep track of logs better.</p><pre>import os<br>import time<br>import requests<br>import json<br>import logging<br>from kafka import KafkaProducer<br><br>logging.basicConfig(level=logging.INFO,<br>                    format=&#39;%(asctime)s:%(funcName)s:%(levelname)s:%(message)s&#39;)<br>logger = logging.getLogger(&quot;crypto_data_stream&quot;)</pre><p>The following part will be sending the retrieved data to the Kafka topic.</p><pre>def process_and_send_data(producer, data, symbol, topic):<br>    &quot;&quot;&quot;<br>    Modifies the data and sends it to the topic<br>    &quot;&quot;&quot;<br>    price_data = data[&#39;data&#39;][symbol][&#39;quote&#39;][&#39;USD&#39;]<br><br>    extracted_data = {<br>        &#39;timestamp&#39;: data[&#39;status&#39;][&#39;timestamp&#39;],<br>        &#39;name&#39;: data[&#39;data&#39;][symbol][&#39;name&#39;],<br>        &#39;price&#39;: price_data[&#39;price&#39;],<br>        &#39;volume_24h&#39;: price_data[&#39;volume_24h&#39;],<br>        &#39;percent_change_24h&#39;: price_data[&#39;percent_change_24h&#39;]<br>    }<br>    producer.send(topic, json.dumps(extracted_data).encode(&#39;utf-8&#39;))</pre><p>This method will retrieve the data from the API and create a Kafka producer.</p><pre>def data_stream():<br>    &quot;&quot;&quot;<br>    Gets the data from the API and sends it to Kafka producer<br>    &quot;&quot;&quot;<br>    try:<br>        api_key = os.environ.get(&quot;COINMARKETCAP_API_KEY&quot;)<br>    except:<br>        logger.error(f&quot;API key couldn&#39;t be obtained from the os.&quot;)<br>    <br>    producer = KafkaProducer(bootstrap_servers=[&#39;kafka1:19092&#39;, &#39;kafka2:19093&#39;, &#39;kafka3:19094&#39;])    <br><br>    url = &#39;https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest&#39;<br>    parameters = {<br>        &#39;symbol&#39;: &#39;BTC&#39;,<br>        &#39;convert&#39;: &#39;USD&#39;<br>    }<br>    headers = {<br>        &#39;Accepts&#39;: &#39;application/json&#39;,<br>        &#39;X-CMC_PRO_API_KEY&#39;: api_key<br>    }<br><br>    end_time = time.time() + 120 # the script will run for 2 minutes<br>    while True:<br>        if time.time() &gt; end_time:<br>            break<br><br>        response = requests.get(url, headers=headers, params=parameters)<br>        data = json.loads(response.text)<br><br>        process_and_send_data(producer, data, &#39;BTC&#39;, &#39;btc_prices&#39;)<br><br>        time.sleep(10)<br>    <br>    logger.info(&quot;API data sent to Kafka successfully&quot;)</pre><h3>MySQL</h3><p>docker-compose.yaml will also create a MySQL server and phpMyAdmin to visualize the database table btc_prices. Every env variable is located in docker-compose.yaml. I also defined them in the scripts.</p><pre>db:<br>    image: mysql:8.0<br>    container_name: mysql<br>    restart: always<br>    environment:<br>      MYSQL_DATABASE: mysql<br>      MYSQL_USER: mysql<br>      MYSQL_PASSWORD: mysql<br>      MYSQL_ROOT_PASSWORD: mysql<br>    ports:<br>      - &#39;3306:3306&#39;<br>    volumes:<br>      - my-db:/var/lib/mysql<br>    networks:<br>      - all-network<br><br>phpmyadmin:<br>    image: phpmyadmin/phpmyadmin:latest<br>    container_name: metabase_pma<br>    links:<br>      - db<br>    environment:<br>      PMA_HOST: db<br>      PMA_PORT: 3306<br>      PMA_ARBITRARY: 1<br>    restart: always<br>    ports:<br>      - 8185:80<br>    networks:<br>      - all-network<br><br>volumes:<br>  my-db:</pre><p>By running the following command, we can access to MySQL server. You will be prompted with the password after running.</p><pre>docker exec -it mysql mysql -u mysql -p</pre><p>You may also take a look at the below article to have more information about local MySQL server connection.</p><p><a href="https://medium.com/@dogukannulu/how-to-create-database-user-and-access-to-mysql-6f78ba4cbd3e">How to Create Database, User, and Access to MySQL</a></p><p>After access, we can run the following commands and see that the Kafka topic messages are inserted into the MySQL table successfully.</p><pre>SHOW databases;<br>USE mysql;<br>SHOW tables;<br>select * from btc_prices;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*IefMbo4bX1Saz8Dk.png" /></figure><p>Alternatively, we can access MySQL UI (phpMyAdmin) <a href="http://localhost:8185/">localhost:8185</a> and run SQL queries on btc_prices. This is a more comfortable way of retrieving data from the MySQL database. I will be using this way.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*uObVTIMYOMoUeaYZ.png" /></figure><p>We have created the Kafka topic and set up the MySQL server. Even though we will be running the scripts as Airflow DAGs, I would like to introduce the necessary parts here.</p><p>First of all, we have to import the necessary libraries and define the logger again.</p><pre>import mysql.connector<br>import logging<br>import json<br>import time<br>from kafka import KafkaConsumer<br><br>logging.basicConfig(level=logging.INFO,<br>                    format=&#39;%(asctime)s:%(funcName)s:%(levelname)s:%(message)s&#39;)<br>logger = logging.getLogger(&quot;read_kafka_write_mysql&quot;)</pre><p>We will connect to MySQL and Kafka with these blocks:</p><pre>try:<br>    conn = mysql.connector.connect(<br>        host=&quot;mysql&quot;,<br>        database=&quot;mysql&quot;,<br>        user=&quot;mysql&quot;,<br>        password=&quot;mysql&quot;,<br>        port=3306<br>    )<br>    cur = conn.cursor()<br>    logger.info(&#39;MySQL server connection is successful&#39;)<br>except Exception as e:<br>    logger.error(f&quot;Couldn&#39;t create the MySQL connection due to: {e}&quot;)<br><br>try:<br>    consumer = KafkaConsumer(<br>        &#39;btc_prices&#39;,<br>        bootstrap_servers=[&#39;kafka1:19092&#39;, &#39;kafka2:19093&#39;, &#39;kafka3:19094&#39;],<br>        auto_offset_reset=&#39;latest&#39;,  # Start consuming from the latest offset<br>        enable_auto_commit=False  # Disable auto-commit to have manual control over offsets<br>    )<br>    logger.info(&quot;Kafka connection successful&quot;)<br>except Exception as e:<br>    logger.error(f&quot;Kafka connection is not successsful. {e}&quot;)</pre><p>We have to first create the necessary table in the MySQL database. We will be writing the data into this table later.</p><pre>def create_new_tables_in_mysql():<br>    &quot;&quot;&quot;<br>    Creates the btc_prices table in MySQL server.<br>    &quot;&quot;&quot;<br>    try:<br>        cur.execute(&quot;&quot;&quot;CREATE TABLE IF NOT EXISTS btc_prices <br>        (timestamp VARCHAR(50), name VARCHAR(10), price FLOAT, volume_24h FLOAT, percent_change_24h FLOAT)&quot;&quot;&quot;)<br>        logging.info(&quot;Table created successfully in MySQL server&quot;)<br>    except Exception as e:<br>        logging.error(f&#39;Tables cannot be created due to: {e}&#39;)</pre><p>Last but not least, we will send the data coming from the Kafka topic to MySQL for 2 minutes straight. Only newly coming data will be sent.</p><pre>def insert_data_into_mysql():<br>    &quot;&quot;&quot;<br>    Insert the latest messages coming to Kafka consumer to MySQL table.<br>    &quot;&quot;&quot;<br>    end_time = time.time() + 120 # the script will run for 2 minutes<br>    for msg in consumer: <br>        if time.time() &gt; end_time:<br>            break<br><br>        kafka_message = json.loads(msg.value.decode(&#39;utf-8&#39;))<br>        try:<br>            # Create an SQL INSERT statement<br>            insert_query = f&quot;INSERT INTO btc_prices (timestamp, name, price, volume_24h, percent_change_24h) VALUES (%s, %s, %s, %s, %s)&quot;<br>            cur.execute(insert_query, (kafka_message[&#39;timestamp&#39;], kafka_message[&#39;name&#39;], kafka_message[&#39;price&#39;], kafka_message[&#39;volume_24h&#39;], kafka_message[&#39;percent_change_24h&#39;]))<br>            conn.commit()<br>        except Exception as e:<br>            logging.error(f&quot;Error inserting data: {e}&quot;)<br>    <br>    logger.info(&quot;Data coming from Kafka topic inserted successfully to MySQL.&quot;)</pre><h3>Running DAGs</h3><p>So far, we have created the Kafka topic, got MySQL database and table ready and we have all the necessary scripts. We should move all .py scripts under the dags folder in the repo. The following dag script will be used to create the DAG and we will be able to see that in the UI.</p><pre>from datetime import timedelta<br>from airflow import DAG<br>from airflow.operators.python_operator import PythonOperator<br>from datetime import datetime<br>from crypto_data_stream import data_stream<br>from read_kafka_write_mysql import create_new_tables_in_mysql, insert_data_into_mysql<br><br>start_date = datetime(2018, 12, 21, 12, 12)<br><br>default_args = {<br>    &#39;owner&#39;: &#39;airflow&#39;,<br>    &#39;start_date&#39;: start_date,<br>    &#39;retries&#39;: 1,<br>    &#39;retry_delay&#39;: timedelta(seconds=5)<br>}<br><br>with DAG(&#39;crypto_data_stream&#39;, default_args=default_args, schedule_interval=&#39;*/10 * * * *&#39;, catchup=False) as dag:<br><br><br>    data_stream_task = PythonOperator(<br>    task_id=&#39;data_stream&#39;,<br>    python_callable=data_stream,<br>    dag=dag,<br>    )<br><br>    create_new_tables_in_mysql_task = PythonOperator(<br>    task_id=&#39;create_new_tables_in_mysql&#39;,<br>    python_callable=create_new_tables_in_mysql,<br>    dag=dag,<br>    )<br><br>    insert_data_into_mysql_task = PythonOperator(<br>    task_id=&#39;insert_data_into_mysql&#39;,<br>    python_callable=insert_data_into_mysql,<br>    dag=dag,<br>    )<br><br>    data_stream_task<br><br>    create_new_tables_in_mysql_task &gt;&gt; insert_data_into_mysql_task</pre><p>Then, we can see that crypto_data_stream appears on the DAGS page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*eDSAqo2uPkL6x6Fu.png" /></figure><p>When we turn the <strong>OFF</strong> button to <strong>ON</strong>, we see that the data will be sent to the Kafka topic every 10 seconds and written to the MySQL table in parallel. We can also check whether the data is sent successfully from the Kafka UI.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DNV8DDUvizYzbiUb.png" /></figure><h3>Metabase</h3><p>Since we already sent the data to MySQL, we can now start creating dashboards in Metabase. Metabase also runs as a Docker container using docker-compose.yaml.</p><pre>  metabase-app:<br>    image: metabase/metabase<br>    container_name: metabase<br>    restart: always<br>    ports:<br>      - 3000:3000<br>    volumes:<br>      - ./metabase-data:/metabase-data<br>    environment:<br>      MB_DB_TYPE: mysql<br>      MB_DB_DBNAME: mysql<br>      MB_DB_PORT: 3306<br>      MB_DB_USER: mysql<br>      MB_DB_PASS: &quot;mysql&quot;<br>      MB_DB_HOST: mysql<br>    depends_on:<br>      - db<br>    links:<br>      - db<br>    networks:<br>      - all-network</pre><p>We can access it via <a href="http://localhost:3000/">localhost:3000</a>. Once we access the UI, we have to configure the MySQL database according to the env variables defined in docker (MySQL env variables). Then, we can see that the MySQL data appears on Metabase if we query:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/0*-brgDsRZApTL-beS.png" /></figure><p>After seeing that the data appears correctly, we can create dynamic dashboards with the desired data. If we set the auto-refresh to 1 minute, the dashboard refreshes itself every 1 minute, and new data arrives every 10 seconds to the MySQL table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Xfd_ESl-G_HcTkPD.png" /></figure><p>In the end, we have a real-time dashboard that shows the real-time BTC price data in Metabase. We can monitor the data via our local machine. We can modify how long the data will be retrieved (3 minutes for this specific project).</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=27c805f56535" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Production Text-to-SQL Engine That Refuses to Run Expensive Queries — Here’s How]]></title>
            <link>https://medium.com/@dogukannulu/production-text-to-sql-engine-that-refuses-to-run-expensive-queries-heres-how-124ff9f06cd1?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/124ff9f06cd1</guid>
            <category><![CDATA[claude]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Thu, 30 Jul 2026 17:20:23 GMT</pubDate>
            <atom:updated>2026-07-30T17:20:23.149Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VobHTHVzT3QxfjUdqqlBHw.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/query-mind">https://github.com/dogukannulu/query-mind</a></p><p>Every week another text-to-SQL tutorial drops. You paste your schema into a prompt, the LLM spits out a SELECT, and the author calls it a day. It works great — until your analyst asks &quot;show me all orders&quot; and Claude generates an unfiltered full-table scan that kills your database, costs you $40 in compute, and returns 2 million rows to a Streamlit table that crashes the browser.</p><p>Production text-to-SQL is a different discipline. It is not about whether you can get an LLM to write SQL. It is about what happens when the SQL is wrong, when the query is too expensive, when the same question gets asked fifty times a day, and when a non-engineer needs to understand the result. Those are the problems I set out to solve when I built <strong>QueryMind</strong> — a production-grade text-to-SQL engine for UDogAI, a fictional e-commerce analytics team.</p><p>This article walks through every layer of the system: the RAG schema layer that makes the AI schema-aware, the self-correction loop that fixes bad SQL before it touches the database, the cost guardrail that rejects dangerous queries before execution, the semantic cache that eliminates redundant LLM calls, the plain-English result explainer, and the OpenTelemetry instrumentation that gives you a Gantt timeline of every pipeline stage. By the end you will have a clear picture of what separates a demo from a deployed system.</p><h3>Section 1: The Problem With Every Text-to-SQL Tutorial</h3><p>The gap between a tutorial and a production system is not the LLM call. That part is easy. The gap is everything around it. Here is how QueryMind compares to the typical tutorial approach:</p><p>FeatureTypical TutorialQueryMindSchema awarenessHardcoded table list in the promptRAG over introspected schema + dbt column docsSQL qualitySingle LLM call, hope for the bestSelf-correction loop: parse → fix → retry (max 3)Cost controlNonePostgreSQL EXPLAIN row count estimator + configurable capCachingNoneSemantic cache via Qdrant cosine similarity (threshold 0.92)Result handlingRaw dataframe dumped to screenPlain-English explanation + 3 clickable follow-up questionsObservabilityNoneFull OpenTelemetry tracing to PostgreSQL, Gantt timeline in StreamlitEvaluationNoneGEval SQL correctness + explanation quality benchmarkSecurityApplication-level onlysqlglot keyword filter + read-only PostgreSQL role enforced at DB level</p><p>Each row in that table represents a failure mode that I have seen cause real problems. None of them require a bigger model. They require better engineering around the model.</p><h3>Section 2: The Architecture — Eight Steps, One Pipeline</h3><p>QueryMind runs every natural language question through an eight-step pipeline. Each step is a separate Python module with a single responsibility. Here is what happens when you type “Which product category had the highest revenue last month?”</p><ol><li><strong>Semantic Cache Check.</strong> The question is embedded with all-MiniLM-L6-v2 (a local, free, 384-dimensional model) and searched against the query_cache Qdrant collection. Cosine similarity above 0.92 is a cache hit — the stored SQL, explanation, and follow-ups are returned instantly. No LLM call made.</li><li><strong>Schema Retrieval.</strong> On a cache miss, the question embedding is searched against the schema_index Qdrant collection. The top-3 most relevant table schema documents are retrieved. These documents are rich — they contain column types, foreign key relationships, dbt descriptions, and sample rows.</li><li><strong>SQL Generation.</strong> Claude Haiku receives the retrieved schema context and the user’s question. It returns a raw SQL string. The system prompt forbids markdown fences, SELECT *, and any write operations.</li><li><strong>SQL Validation + Self-Correction.</strong> sqlglot parses the SQL in the PostgreSQL dialect. If parsing fails, the SQL and error message are sent back to Claude with a correction prompt. This loop runs up to 3 times. More than 95% of queries succeed on the first attempt.</li><li><strong>Cost Estimation.</strong> EXPLAIN {sql} runs against the read-only PostgreSQL connection. The rows=N estimate is extracted from the query planner output. If the estimate exceeds COST_CAP_ROWS (default 10,000), the query is rejected with a user-friendly message before any data moves.</li><li><strong>Query Execution.</strong> The validated, cost-approved SQL runs against a dedicated readonly_user PostgreSQL role. Results are capped at 500 rows in the application layer. Even if that cap were removed, the database role cannot perform writes — enforced at the PostgreSQL level, not just in code.</li><li><strong>Result Explanation + Follow-ups.</strong> The first 10 rows of results and the original question are sent to Claude Haiku. It returns a JSON object with a 2–3 sentence plain-English explanation and three follow-up questions. Non-technical stakeholders get a narrative, not a raw table.</li><li><strong>Cache Store + History Log.</strong> The question embedding, SQL, explanation, and follow-ups are stored in Qdrant for future cache hits. Every query — cache hit or miss, success or failure — is written to the query_history table in a separate history database.</li></ol><p>The full stack: PostgreSQL 15, dbt-core 1.7, sentence-transformers, Qdrant 1.7, sqlglot, Claude Haiku (claude-haiku-4-5-20251001), FastAPI, Streamlit, DeepEval GEval, OpenTelemetry SDK, Docker Compose. Everything runs locally with a single make up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V4a5L3qOt5_0SzWrWlWEcA.png" /></figure><h3>Section 3: The RAG Layer — Why Schema + dbt Docs Beat Schema Alone</h3><p>Most text-to-SQL systems pass the schema to the LLM as a list of table names and column types. That is necessary but not sufficient. The column status TEXT tells Claude nothing. The column status TEXT — one of: completed (delivered and closed), pending (received, awaiting processing), shipped (dispatched and in transit), cancelled (cancelled before delivery) tells Claude exactly what to put in a WHERE clause.</p><p>QueryMind builds schema documents in two passes. First, SQLAlchemy inspects the live database: table names, column names, data types, nullable flags, primary key flags, foreign key relationships, approximate row counts, and three sample rows per table. Second, the dbt manifest (manifest.json, generated by dbt run) is parsed for model-level descriptions and column-level business context.</p><p>The resulting document for the orders table looks like this:</p><pre>Table: orders<br>Description: One row per customer order placed on the UDogAI platform.<br>Updated in real-time by the fulfilment system. Use this table to<br>analyse revenue, order volumes, and fulfilment rates.<br>Approximate row count: 2,000<br>Columns:<br>  order_id (INTEGER) [PK] [NOT NULL] — Unique order identifier.<br>  customer_id (INTEGER) [NOT NULL] — References customers.customer_id.<br>  product_id (INTEGER) [NOT NULL] — References products.product_id.<br>  order_date (DATE) [NOT NULL] — Calendar date the order was placed.<br>  quantity (INTEGER) [NOT NULL] — Number of units ordered. Min 1.<br>  unit_price (NUMERIC) [NOT NULL] — Price per unit in USD at time of order.<br>  total_amount (NUMERIC) [NOT NULL] — Always equals quantity × unit_price.<br>  status (TEXT) [NOT NULL] — One of: completed (delivered), pending<br>    (awaiting processing), shipped (in transit), cancelled (before delivery).<br>Relationships (foreign keys):<br>  orders.customer_id → customers.customer_id<br>  orders.product_id → products.product_id<br>Sample row: order_id=1, customer_id=1, product_id=1, quantity=1, status=&#39;completed&#39;</pre><p>Each table’s document is embedded into a 384-dimensional vector using all-MiniLM-L6-v2, which runs locally with no API cost and no latency overhead. The three table vectors are stored in the schema_index Qdrant collection. At query time, the top-3 most relevant tables are retrieved and their full documents are concatenated into the schema context sent to Claude. The model knows not just the column names — it knows the business meaning behind every value.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MlJeyjsrpn_lp0u1WdpDzQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Er0XoGfv65mqhDMqbzIG1w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G5WbYMpBMAr6cB_iZ-YQjQ.png" /></figure><h3>Section 4: The Self-Correction Loop — Making SQL Reliable Without a Human</h3><p>A single LLM call produces correct SQL most of the time. “Most of the time” is not good enough for a system that non-engineers will use unsupervised. QueryMind adds a validation layer powered by sqlglot, a pure-Python SQL parser with full PostgreSQL dialect support.</p><p>Validation runs in two passes. First, a forbidden keyword scan: if the generated SQL contains INSERT, UPDATE, DELETE, DROP, CREATE, TRUNCATE, ALTER, GRANT, or REVOKE, it is rejected immediately regardless of syntax correctness. Second, sqlglot.parse_one(sql, dialect=&quot;postgres&quot;) is called — any parse error surfaces the exact error message.</p><p>If validation fails, the self-correction prompt fires:</p><pre>The SQL query below has a syntax error.<br>Original question: {question}<br>Invalid SQL:<br>{sql}<br>Error: {error}<br>Fix the SQL. Return ONLY the corrected SQL query, nothing else.</pre><p>Claude receives the broken SQL and the exact error from sqlglot, produces a corrected version, and the validation loop runs again. This continues for up to three attempts. In practice, more than 95% of queries pass on the first attempt. The correction loop exists for the 5% — complex multi-join queries, window functions, dialect-specific date arithmetic — where the first attempt has a minor syntax issue that a one-shot fix resolves instantly.</p><p>The key insight: sqlglot is used as a <em>signal</em>, not a full semantic validator. It catches syntax errors and forbidden operations. The read-only database role catches anything that slips through. Defense in depth, not a single gate.</p><h3>Section 5: The Cost Guardrail — The Feature Every Tutorial Skips</h3><p>This is the feature I am most proud of, and the one I have never seen in a text-to-SQL tutorial. Before any query touches the database for real data, QueryMind asks PostgreSQL’s query planner what it thinks will happen:</p><pre>EXPLAIN SELECT o.product_id, SUM(o.total_amount)<br>FROM orders o<br>GROUP BY o.product_id<br>ORDER BY 2 DESC<br>LIMIT 5</pre><p>The planner responds with an execution plan containing a rows=N estimate. QueryMind extracts that number with a regex and compares it against COST_CAP_ROWS (configurable via environment variable, default 10,000). If the estimate exceeds the cap, the query is rejected before a single data row is read:</p><blockquote><em>Query rejected: estimated 45,000 rows exceeds the cap of 10,000 rows. Add a more specific WHERE clause to narrow the result set.</em></blockquote><p>The user gets an actionable error message, not a spinner that runs for 30 seconds and then crashes. The analyst learns to write more specific questions. The database stays healthy.</p><p>There is also a second, independent safety layer: the PostgreSQL readonly_user role. This role has SELECT privileges only. INSERT, UPDATE, DELETE, and TRUNCATE are explicitly revoked at the database level. Even if a bug in the self-correction loop produced a valid-looking DROP TABLE statement, PostgreSQL would reject it with a permission error. Application-level validation is the first line of defense. Database-level permissions are the safety net that cannot be bypassed in code.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RteRItTaBnw-hNWkTgAHfA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*33ufUh3TAoTWZhkm-zI20Q.png" /></figure><h3>Section 6: Semantic Caching — Cutting LLM Costs by 40%</h3><p>The most expensive part of any LLM-powered system is the LLM call. The cheapest call is the one you never make. QueryMind uses semantic caching to return previously computed results for questions that are semantically equivalent to past questions — even if the wording is different.</p><p>When a question is asked for the first time, its embedding is stored in Qdrant alongside the SQL, explanation, follow-up questions, and result preview. When the next question comes in, its embedding is compared against all cached embeddings using cosine similarity. A score above 0.92 is treated as a cache hit.</p><p>This means “Top 5 products by revenue” and “What are the five highest-revenue products?” resolve to the same cached result. The threshold of 0.92 is deliberately tight — it catches true paraphrases while rejecting genuinely different questions that happen to share vocabulary.</p><p>What gets cached:</p><ul><li>The generated SQL</li><li>The plain-English explanation</li><li>The three follow-up questions</li><li>The result preview (first 5 rows)</li><li>The similarity score of the cache hit</li></ul><p>In practice, after the first day of use by a team, cache hit rates of 40% or more are typical. The analytics team asks similar questions repeatedly — “revenue this month”, “active customers by country”, “top products” — and the cache absorbs most of them. Each cache hit saves approximately $0.002 in API costs and shaves 2–3 seconds off the response time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ykI48iS0kZfz8T2AIK2VOw.png" /></figure><h3>Section 7: Result Explanation and Follow-up Questions</h3><p>Raw query results are useless to non-technical stakeholders. A table with columns product_id, product_name, and sum tells a product manager nothing. The explanation step closes this gap.</p><p>After execution, the first 10 rows of results are sent to Claude Haiku alongside the original question and the SQL that was run. The model returns a structured JSON response:</p><pre>{<br>  &quot;explanation&quot;: &quot;Electronics products generated $187,432 in revenue this month,<br>    accounting for 73% of total revenue. The Laptop Pro 15 was the top performer<br>    with $52,000, followed by the Monitor 27\&quot; at $31,800.&quot;,<br>  &quot;follow_up_questions&quot;: [<br>    &quot;How does this month&#39;s electronics revenue compare to last month?&quot;,<br>    &quot;Which individual products had the highest revenue growth?&quot;,<br>    &quot;What is the average order value for electronics versus furniture?&quot;<br>  ]<br>}</pre><p>The explanation uses specific numbers from the results. It is written for a non-technical reader. The three follow-up questions are clickable buttons in the Streamlit interface — clicking one pre-populates the question input and runs the pipeline immediately. This turns a one-shot query tool into a conversational analytics experience.</p><p>One important implementation detail: the explanation prompt sends at most 10 rows to the LLM. Sending unbounded results to an API is both expensive and potentially dangerous if results contain sensitive data. The application layer caps results at 500 rows; the explanation layer caps the LLM input at 10 rows. Both caps are enforced in code.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*635vAvU5-pS3tnE6c9KDLA.png" /></figure><h3>Section 8: Observability — A Gantt Timeline of Every Pipeline Stage</h3><p>When a query takes four seconds, which stage consumed the time? Was it Claude’s API latency? The Qdrant vector search? The PostgreSQL EXPLAIN call? Without distributed tracing, you cannot answer that question. QueryMind instruments every pipeline stage with OpenTelemetry spans.</p><p>Rather than running an OpenTelemetry collector (which adds infrastructure complexity), QueryMind uses a custom PgSpanExporter that writes spans directly to a otel_traces table in the history database. Each row represents one pipeline stage:</p><pre>span_id, trace_id, parent_span_id, operation_name,<br>start_time, end_time, duration_ms, status, attributes</pre><p>The eight pipeline stages each become a named span:</p><ul><li>querymind.cache.check</li><li>querymind.schema.retrieval</li><li>querymind.sql.generation</li><li>querymind.sql.validation</li><li>querymind.cost.estimation</li><li>querymind.sql.execution</li><li>querymind.result.explanation</li><li>querymind.cache.store</li></ul><p>All eight are children of a root querymind.pipeline span that covers the full request. Span attributes carry context: question_hash, tables_retrieved, correction_attempts, estimated_rows, actual_rows.</p><p>A separate metrics module accumulates counters and gauges in memory and flushes them to an otel_metrics table every 30 seconds via a background thread. Metrics include: query count (by outcome), pipeline duration, cache similarity scores, correction attempt counts, and SQL execution time.</p><p>The Streamlit Observability page renders these as a Plotly Gantt chart — each span as a coloured bar on a timeline, grouped by operation_name, coloured green for OK and red for ERROR. P50 and P95 latency metrics sit above the chart. This is the kind of operational visibility that makes a deployed system debuggable by someone who was not the original author.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l_Hes1Vw379PU4BrX0NwOQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xtulM11WoXVH0p6caq251Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MFOYFXfpyZ5AEY_h6EcxlQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eddokDIBRAfyjso4lCXTIA.png" /></figure><h3>Section 9: The GEval Benchmark — Putting a Number on SQL Quality</h3><p>Every text-to-SQL system claims to be accurate. QueryMind has a number: <strong>SQL Correctness 0.87, Explanation Quality 0.91</strong> on a 10-question golden dataset evaluated with DeepEval’s GEval metric.</p><p>The golden dataset spans three complexity tiers:</p><ul><li><strong>Simple (3 questions):</strong> counts, sums, single-table filters. Example: “How many orders are in the database?”</li><li><strong>Medium (4 questions):</strong> joins, GROUP BY, HAVING clauses. Example: “What are the top 5 products by total revenue?”</li><li><strong>Complex (3 questions):</strong> date arithmetic, window functions, multi-join aggregations. Example: “What is the monthly revenue trend for the last 6 months?”</li></ul><p>GEval uses Claude as the judge, scoring each output against a rubric: does the SQL query the right tables, apply correct filters, use appropriate aggregations, and include the necessary JOINs? The threshold for passing is 0.75 on both metrics. Simple queries scored above 0.95. Complex queries — the ones involving DATE_TRUNC, multi-table joins, and window functions — pulled the average down to 0.87, which is where self-correction earns its keep.</p><p>Running the full evaluation costs approximately $0.12 in Claude API calls. It takes about three minutes. It is repeatable. That is the point: quantitative evaluation is not optional for a system that non-engineers will rely on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wQqkbu03CAk0bSBQi79iyQ.png" /></figure><h3>Section 10: What I Would Do Differently</h3><p>Building QueryMind in twelve steps taught me a few things I would change in a second iteration.</p><p><strong>Streaming the explanation.</strong> The explanation step is the slowest visible latency for the user — they have the SQL and the results table, but they are waiting for the explanation to appear. Streaming the Claude response token by token would make the UI feel dramatically faster even if the total wall-clock time is the same.</p><p><strong>A feedback button.</strong> The most valuable training signal for a text-to-SQL system is a user marking a result as wrong. A thumbs-down button that logs the question, the generated SQL, and the user’s correction to the history database would create a dataset for future fine-tuning or few-shot prompt improvement. I left it out to keep the scope manageable, but it is the first thing I would add.</p><p><strong>Domain-specific embedding fine-tuning.</strong> The all-MiniLM-L6-v2 model works well for general semantic similarity, but it has no concept of database terminology. &quot;Orders placed last month&quot; and &quot;recent order activity&quot; embed closer together than they should. Fine-tuning on pairs of equivalent analytics questions would push the cache hit rate higher and make schema retrieval more precise.</p><p><strong>Result-level caching at the database layer.</strong> The semantic cache stores LLM outputs. But the underlying query result — the actual rows — could also be cached at the database level using PostgreSQL’s materialized views or a Redis layer. For dashboards where the same query runs every five minutes, this would eliminate DB load entirely.</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=124ff9f06cd1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Self Healing Data Pipeline -LangGraph, Claude, QDrant, Great Expectations, GEval]]></title>
            <link>https://medium.com/@dogukannulu/self-healing-data-pipeline-langgraph-claude-qdrant-great-expectations-geval-6dd2a17cf787?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/6dd2a17cf787</guid>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[deepeval]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[claude]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Tue, 28 Jul 2026 13:21:14 GMT</pubDate>
            <atom:updated>2026-07-28T13:21:14.403Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8EDOYPH3tBzIhW1oV0dFVw.png" /></figure><p><a href="https://github.com/dogukannulu/self-healing-pipeline">https://github.com/dogukannulu/self-healing-pipeline</a></p><p>It’s 3 AM. Your Slack lights up. A data quality check failed somewhere upstream, and now the dashboard your CEO looks at every morning is showing wrong numbers. You spend two hours digging through logs, tracing the failure to a typo in a status field that got through without validation. The fix takes five minutes. The diagnosis took two hours.</p><p>This happens on every data team. And it keeps happening because we’ve solved the <em>detection</em> problem — Great Expectations, dbt tests, and similar tools catch failures reliably — but we haven’t solved the <em>diagnosis</em> problem. Knowing that expect_column_values_to_be_in_set failed on orders.status tells you <em>what</em> broke. It tells you nothing about <em>why</em>, or <em>what to do about it</em>.</p><p>This article shows how I built a pipeline that closes that gap. Great Expectations catches failures. A LangGraph agent — backed by Claude — diagnoses them, searches historical patterns in Qdrant vector memory, and generates a specific, actionable fix suggestion. By the time you wake up, every failure has a root cause and a fix command ready.</p><h3>The Architecture</h3><p>Five layers, each with a clear responsibility:</p><pre>SOURCE DATABASE (PostgreSQL)<br>  └── orders, customers, products (with intentional quality issues)<br>          │<br>          ▼ scheduled every 30 minutes<br>ORCHESTRATION (Apache Airflow)<br>  └── data_quality_dag<br>          │<br>          ▼ on failure<br>VALIDATION (Great Expectations)<br>  └── 3 expectation suites, 3 checkpoints<br>          │<br>          ▼ failure events<br>AI DIAGNOSIS (LangGraph Agent)<br>  ├── Node 1: Parse failure from metadata DB<br>  ├── Node 2: Search Qdrant for similar past failures<br>  ├── Node 3: Claude diagnoses root cause<br>  ├── Node 4: Claude generates fix suggestion + SQL<br>  └── Node 5: Write incident report to metadata DB<br>          │<br>          ▼<br>STORAGE + PRESENTATION<br>  ├── PostgreSQL metadata DB (validation_runs, failure_events, incident_reports)<br>  ├── Qdrant (vector memory of past failures)<br>  └── Streamlit dashboard</pre><p>The entire stack runs locally with a single docker compose up. The only external dependency is an Anthropic API key, which costs about $0.002 per failure diagnosed.</p><h3>The Stack</h3><ul><li><strong>Orchestration:</strong> Apache Airflow 2.8</li><li><strong>Data quality:</strong> Great Expectations 0.18</li><li><strong>Source DB:</strong> PostgreSQL 15 (port 5433)</li><li><strong>Metadata DB:</strong> PostgreSQL 15 (port 5434) — intentionally separate from source</li><li><strong>AI agent:</strong> LangGraph 0.0.40 + Claude Haiku</li><li><strong>Vector memory:</strong> Qdrant 1.7.4</li><li><strong>LLM:</strong> claude-haiku-4–5–20251001 (~$0.002 per diagnosis)</li><li><strong>Dashboard:</strong> Streamlit</li><li><strong>Evaluation:</strong> DeepEval (GEval)</li></ul><p>Two separate PostgreSQL instances is intentional and important. Mixing business data and pipeline metadata in the same database is an anti-pattern in production — schema changes in one affect the other, access controls get messy, and you lose the ability to take down the pipeline database without affecting the source.</p><h3>Step 1: Seed Realistic Bad Data</h3><p>The source database has three tables (and these represent sample data of the company) — orders, customers, products — seeded with 500 orders, 200 customers, and 8 products of clean baseline data. Then I inject 8 specific failure patterns:</p><ul><li>Invalid order status values (compelted, PENDING, unknown)</li><li>NULL emails in the customers table</li><li>Ages out of valid range (negative values, 200-year-olds)</li><li>Invalid ISO country codes (XX, USA, empty string)</li><li>Negative product prices</li><li>Future order dates on completed orders</li><li>Duplicate customer emails</li><li>total_amount that doesn&#39;t match quantity × unit_price</li></ul><p>These aren’t contrived examples. Every one maps to a real failure mode every data engineer has encountered: typos from upstream ETL jobs, ORM bugs that insert default values, units mismatches between systems, and bulk imports that bypass unique constraints.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QMJu-9cyzG9btuDfL25tTw.png" /><figcaption>The bad data is immediately visible. The fix will be too.</figcaption></figure><h3>Step 2: Great Expectations Catches Everything</h3><p>I build the expectation suites programmatically — not through the GUI wizard. This is the professional approach: suites live in version control, they’re reviewable in PRs, and they’re reproducible.</p><pre>def build_orders_suite(context: gx.DataContext) -&gt; None:<br>    suite = context.add_or_update_expectation_suite(&quot;orders_suite&quot;)<br>    asset = datasource.add_table_asset(name=&quot;orders&quot;, table_name=&quot;orders&quot;)<br>    validator = context.get_validator(<br>        batch_request=asset.build_batch_request(),<br>        expectation_suite=suite,<br>    )<br>    # Status must be one of the valid values<br>    validator.expect_column_values_to_be_in_set(<br>        &quot;status&quot;, [&quot;completed&quot;, &quot;pending&quot;, &quot;shipped&quot;, &quot;cancelled&quot;]<br>    )<br>    # Prices must be positive<br>    validator.expect_column_values_to_be_between(<br>        &quot;unit_price&quot;, min_value=0.01, max_value=100000<br>    )<br>    # Order date must not be in the future<br>    validator.expect_column_values_to_be_between(<br>        &quot;order_date&quot;, max_value=date.today()<br>    )<br>    validator.save_expectation_suite(discard_failed_expectations=False)</pre><p>Three suites, three checkpoints, three tables. When Airflow triggers a checkpoint, GX connects to the source database, runs all expectations, and returns a structured result object with per-expectation pass/fail, unexpected counts, percentages, and sample bad values.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vh5Eha_noPgdFofivcC8NA.png" /></figure><h3>Step 3: Airflow Orchestrates Everything</h3><p>The data_quality_pipeline DAG runs every 30 minutes. Three validation tasks run in parallel, then all feed into a single ai_diagnosis task that processes every failure event found.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rZ3zYTSMhNiUIbHbNs05-w.png" /><figcaption>The DAG makes the pipeline structure explicit and auditable.</figcaption></figure><p>The key design decision: trigger_rule=&quot;all_done&quot; on the diagnosis task. This means even if one validation task fails hard (not just reports GX failures — actually crashes), the agent still runs on the failures the other tasks found. You don&#39;t want one broken checkpoint to block diagnosis of valid failures.</p><p>Every GX result gets written to a metadata database — separate from the source DB — with the full structured result JSON, timing, and individual failure events. The Streamlit dashboard and the agent both read exclusively from this metadata DB. The source DB is read-only for the pipeline.</p><h3>Step 4: The LangGraph Agent — 5 Nodes, Explicit State</h3><p>This is the core of the project. The agent is a 5-node LangGraph state machine. Every node does exactly one thing. The state is explicit and inspectable between nodes.</p><pre>def build_graph() -&gt; StateGraph:<br>    graph = StateGraph(AgentState)<br>    graph.add_node(&quot;parse_failure&quot;,  parse_failure_node)<br>    graph.add_node(&quot;search_memory&quot;,  search_memory_node)<br>    graph.add_node(&quot;diagnose&quot;,       diagnose_node)<br>    graph.add_node(&quot;suggest_fix&quot;,    suggest_fix_node)<br>    graph.add_node(&quot;report&quot;,         report_node)<br>    graph.set_entry_point(&quot;parse_failure&quot;)<br>    graph.add_edge(&quot;parse_failure&quot;, &quot;search_memory&quot;)<br>    graph.add_edge(&quot;search_memory&quot;, &quot;diagnose&quot;)<br>    graph.add_edge(&quot;diagnose&quot;,      &quot;suggest_fix&quot;)<br>    graph.add_edge(&quot;suggest_fix&quot;,   &quot;report&quot;)<br>    graph.add_edge(&quot;report&quot;,        END)<br>    return graph.compile()</pre><p>This explicit state machine approach is the opposite of a black-box LLM call. If diagnosis is wrong, you fix one node. If the fix suggestions are too vague, you improve the prompt in agent/prompts.py without touching anything else. Each node is independently testable.</p><h3>Node 1: Parse Failure</h3><p>Loads the failure event from the metadata DB into the agent state. Gets the table, column, expectation type, failure message, unexpected count, percentage, and sample bad values.</p><h3>Node 2: Search Memory</h3><p>Embeds the current failure as a 1536-dimensional vector and searches Qdrant for similar past failures. Also stores the current failure for future searches. This is how the agent learns — after a few weeks, it can tell you “we saw a similar status value issue three weeks ago and it was caused by the upstream ETL job changing its output format.”</p><h3>Node 3: Diagnose (Claude)</h3><pre>DIAGNOSE_PROMPT = &quot;&quot;&quot;You are a senior data engineer diagnosing a data quality failure.FAILURE CONTEXT:<br>- Table: {table_name}<br>- Column: {column_name}<br>- Expectation violated: {expectation_type}<br>- Failure message: {failure_message}<br>- Unexpected row count: {unexpected_count}<br>- Unexpected percentage: {unexpected_pct}%<br>- Sample bad values: {sample_bad_values}<br>SIMILAR PAST FAILURES:<br>{similar_failures}<br>Diagnose the root cause. Categorize as exactly one of:<br>  schema_drift | bad_ingestion | upstream_bug | business_logic | unknown<br>Respond with ONLY a JSON object:<br>{<br>  &quot;root_cause&quot;: &quot;&lt;1-2 sentence explanation&gt;&quot;,<br>  &quot;root_cause_category&quot;: &quot;&quot;<br>}&quot;&quot;&quot;<br></pre><p>The prompt is versioned in agent/prompts.py. It&#39;s not buried in the node function. Changing the prompt is a one-file diff, reviewable in a PR.</p><h3>Node 4: Suggest Fix (Claude)</h3><p>Takes the diagnosed root cause and generates a specific, actionable fix — including SQL when applicable. Rates confidence as high, medium, or low.</p><h3>Node 5: Report</h3><p>Writes the full incident report to the metadata DB and marks the failure event as diagnosed. The Streamlit dashboard reads this immediately.</p><h3>Two Real Diagnoses, Side by Side</h3><p>Here’s what the agent actually produces for two different failure types:</p><p><strong>Failure 1: Invalid order status values</strong></p><ul><li><strong>Category:</strong> bad_ingestion</li><li><strong>Root cause:</strong> “Invalid status values (‘compelted’, ‘PENDING’, ‘unknown’) were inserted by an upstream ETL job that lacks output validation. ‘compelted’ is a typo, ‘PENDING’ uses incorrect casing, and ‘unknown’ is not in the allowed set.”</li><li><strong>Fix suggestion:</strong> “Correct the existing bad values and add a CHECK constraint to prevent future inserts. Update the ETL job to validate status values before writing.”</li><li><strong>Fix SQL:</strong></li></ul><pre>UPDATE orders<br>SET status = CASE<br>    WHEN status = &#39;compelted&#39; THEN &#39;completed&#39;<br>    WHEN status = &#39;PENDING&#39;   THEN &#39;pending&#39;<br>    ELSE NULL<br>END<br>WHERE status NOT IN (&#39;completed&#39;, &#39;pending&#39;, &#39;shipped&#39;, &#39;cancelled&#39;);<br>ALTER TABLE orders<br>ADD CONSTRAINT chk_valid_status<br>CHECK (status IN (&#39;completed&#39;, &#39;pending&#39;, &#39;shipped&#39;, &#39;cancelled&#39;));</pre><p><strong>Failure 2: NULL emails in customers</strong></p><ul><li><strong>Category:</strong> bad_ingestion</li><li><strong>Root cause:</strong> “3 customer records were inserted with NULL email values, likely from a bulk import or API integration that does not enforce email as a required field before writing to the database.”</li><li><strong>Fix suggestion:</strong> “Investigate and backfill the 3 records if possible, then delete or quarantine any that cannot be resolved. Add a NOT NULL constraint to prevent future nulls.”</li><li><strong>Fix SQL:</strong></li></ul><pre>-- Find and review affected records<br>SELECT customer_id, created_at FROM customers WHERE email IS NULL;<br>-- Delete unresolvable records (after review)<br>DELETE FROM customers WHERE email IS NULL;<br>-- Add constraint to prevent future occurrences<br>ALTER TABLE customers ALTER COLUMN email SET NOT NULL;</pre><p>The agent handles completely different failure types correctly. It doesn’t give generic advice — it gives the exact SQL for this table, this column, these bad values.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L-ZyAetOZXQwp3xAkEBEHQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zF6BT5pdtbGzeMjp3nBJxQ.png" /><figcaption>Different failure types, different diagnoses, different fix commands — all generated automatically.</figcaption></figure><h3>Vector Memory: The Agent Gets Smarter Over Time</h3><p>Each failure is embedded as a deterministic 1536-dimensional vector and stored in Qdrant with metadata about the table, column, and expectation type. When a new failure arrives, the agent searches for the most similar past failures before generating its diagnosis.</p><pre>def store_failure(event_id: int, failure_dict: dict) -&gt; None:<br>    &quot;&quot;&quot;Store a failure event in Qdrant vector memory.&quot;&quot;&quot;<br>    failure_text = (<br>        f&quot;table:{failure_dict[&#39;table_name&#39;]} &quot;<br>        f&quot;column:{failure_dict.get(&#39;column_name&#39;, &#39;none&#39;)} &quot;<br>        f&quot;expectation:{failure_dict[&#39;expectation_type&#39;]} &quot;<br>        f&quot;message:{failure_dict[&#39;failure_message&#39;][:200]}&quot;<br>    )<br>    vector = embed_failure(failure_text)<br>    client.upsert(collection_name=&quot;past_failures&quot;,<br>                  points=[PointStruct(id=event_id, vector=vector, payload={...})])</pre><p>After a week of pipeline runs, the agent starts seeing patterns: “this is the third time customers.country_code has failed with invalid values — the similarity score is 0.94 compared to event #12 from Tuesday.&quot; That context goes directly into the diagnosis prompt.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D-bhmpqtybb7juCScQTTtg.png" /><figcaption>Qdrant stores every failure as a vector. The agent’s memory grows with each run</figcaption></figure><h3>The Streamlit Dashboard</h3><p>Three pages: Overview (health trend by table), Active Failures (diagnosed incidents with fix commands), Failure Browser (historical view with filters).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0fHa_3WLgjtL50FHMXNLpg.png" /><figcaption>The health trend makes degradation visible. Red bars mean something needs attention.</figcaption></figure><p>The Active Failures page is the practical center of the dashboard. Every open incident shows the full diagnosis panel: what failed, why (in plain English), what to do about it, and the exact SQL to run.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jN9FFxm3wafNnlhi-Kt7ng.png" /><figcaption>From ‘something failed’ to ‘here’s the SQL to fix it’ — automatically, while you slept.</figcaption></figure><h3>Measuring Fix Quality with GEval</h3><p>Saying “the agent generates fix suggestions” isn’t enough. I used DeepEval’s GEval metric to measure whether the suggestions are actually good across 30 golden failure scenarios.</p><p>Two metrics evaluated against ground-truth fixes:</p><ul><li><strong>Fix Suggestion Relevance</strong> — is the suggestion specific, actionable, and consistent with the root cause? Threshold: 0.75</li><li><strong>Root Cause Category Accuracy</strong> — does the predicted category correctly identify the failure type? Threshold: 0.80</li></ul><p>Results: Fix Suggestion Relevance averaged above 0.85 across the 30 scenarios. High-confidence suggestions (where the agent rated itself “high”) scored consistently above 0.88. Category accuracy exceeded 0.80 semantically — the agent correctly distinguishes between schema drift, bad ingestion, upstream bugs, and business logic failures.</p><p>One honest observation: the distinction between bad_ingestion and upstream_bug is genuinely ambiguous without full context about the source system. The agent defaults to bad_ingestion when there&#39;s no explicit mention of a source system bug. This is actually reasonable behavior — it matches what a human engineer would guess given the same information.</p><h3>The Test Suite: 44 Tests Across 3 Layers</h3><p>Three test suites, each testing a different layer:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-6-GrXcKocl82GPHVTJKxg.png" /><figcaption>Unit tests: 16 tests, zero infrastructure, under 2 seconds.</figcaption></figure><p><strong>Unit tests (16):</strong> Agent nodes tested in isolation with mocked Claude responses. Memory embedding tested for correct dimension, normalization, and determinism. Metadata store functions tested with mocked psycopg2. These run in 1.28 seconds with no Docker dependency.</p><p><strong>Integration tests (20):</strong> End-to-end against the real Docker stack. Bad data seeded → GX detects failures → metadata DB updated → agent diagnoses → incident report written → status transitions verified.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*abTeNxXvjrLDWAjgPC31Vg.png" /><figcaption>Integration tests: 20 tests against the real stack.</figcaption></figure><p><strong>Evaluation tests (8):</strong> GEval quality scoring across 30 golden scenarios. These are the tests that actually measure whether the AI layer is doing its job.</p><h3>What I’d Do Differently</h3><p><strong>Real embeddings.</strong> The current implementation uses a deterministic hash-based pseudo-embedding for local development. In production, swap this for real semantic embeddings — either sentence-transformers running locally or a proper embeddings API. The Qdrant integration is already in place; it&#39;s a one-function change.</p><p><strong>Conditional agent routing.</strong> The current graph is linear: all 5 nodes always run. A production agent would skip the fix suggestion step for unknown-category failures and route high-confidence diagnoses to an auto-remediation path that actually executes the SQL (with human approval).</p><p><strong>Feedback loop.</strong> Engineers should be able to mark a fix suggestion as correct or incorrect. That feedback should update the Qdrant vectors so similar future failures get better diagnoses. Right now the memory only stores what failed — not whether the suggested fix worked.</p><p><strong>Alerting.</strong> The pipeline detects and diagnoses, but doesn’t alert. A Slack webhook in the report_node would complete the loop — the on-call engineer gets a message with the root cause and fix SQL before they even open a laptop.</p><h3>Key Takeaways</h3><ul><li><strong>Detection ≠ diagnosis.</strong> GX tells you what broke. The agent tells you why and what to do. Both layers are necessary.</li><li><strong>Explicit state machines beat black-box LLM calls.</strong> LangGraph’s 5-node graph is inspectable, debuggable, and independently testable. If one node misbehaves, you fix that node.</li><li><strong>Separate your metadata from your source data.</strong> Two PostgreSQL instances, strict separation. This is a production best practice, not over-engineering.</li><li><strong>Measure the AI layer.</strong> GEval gives you a number. “The agent seems to work” isn’t good enough for production — you need to know it works on 30 representative scenarios before you trust it on new ones.</li><li><strong>Cost is not a barrier.</strong> $0.50 a week to automatically diagnose every data quality failure your pipeline catches. The ROI on the first 3 AM page you don’t have to take is immediate.</li></ul><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6dd2a17cf787" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Production-Grade AI-Powered Data Lineage Observability Agent with LangGraph, Airflow, DBT, Neo4j…]]></title>
            <link>https://medium.com/@dogukannulu/production-grade-ai-powered-data-lineage-observability-agent-with-langgraph-airflow-dbt-neo4j-17b4842f0286?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/17b4842f0286</guid>
            <category><![CDATA[airflow]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Tue, 28 Jul 2026 13:18:44 GMT</pubDate>
            <atom:updated>2026-07-28T13:18:44.142Z</atom:updated>
            <content:encoded><![CDATA[<h3>Production-Grade AI-Powered Data Lineage Observability Agent with LangGraph, Airflow, DBT, Neo4j, MLflow, Gradio, GEval</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NO_So7_qBLqd9eqfYU8pXg.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/data-lineage">https://github.com/dogukannulu/data-lineage</a></p><h3>Overview</h3><p>I built a system that automatically investigates Airflow pipeline failures end to end: from the moment a DAG task fails, through a graph walk of the DBT lineage tree, all the way to a written root-cause report stored in PostgreSQL and tracked in MLflow. The agent runs in under 30 seconds at the median, produces a structured hypothesis chain, and gets evaluated by a 50-incident golden test set graded by Claude itself acting as a judge. The entire stack -8 services-is managed by both Docker Compose for day-to-day development and Terraform for reproducible infrastructure-as-code deployments.</p><p>This article is the full technical story of how I built it, every design decision I made and regretted, and what the numbers actually looked like when I ran the evaluation harness.</p><h3>Tech Stack</h3><p>Full list of every dependency with its purpose in the project:</p><ul><li>Apache Airflow 2.9 — orchestration of 12 client DAGs, each running twice daily, with on_failure_callback wired to fire the investigation webhook</li><li>LangGraph — 6-node state machine that sequences the investigation from triage through to report writing</li><li>Anthropic Claude claude-sonnet-4–6 — hypothesis generation in HYPOTHESIZE and LLM-as-judge scoring in the GEval harness</li><li>FastAPI — webhook receiver on /webhook/airflow and REST query endpoints for investigations and lineage</li><li>uvicorn — ASGI server running the FastAPI app inside Docker</li><li>Neo4j 5 Community — graph database holding the DBT lineage graph (84 nodes, 216 edges across 12 clients)</li><li>PostgreSQL 16 — relational store for pipeline metadata (5 tables, 34,534 seeded rows)</li><li>SQLModel — typed ORM models that double as Pydantic validation schemas</li><li>Alembic — database migrations applied on container startup</li><li>MLflow — investigation run tracking without the heavy ML dependency tree</li><li>Pydantic — data validation across all API models and agent state</li><li>Gradio — 4-tab browser dashboard for browsing incidents and triggering manual investigations</li><li>structlog — structured JSON logging throughout the backend</li><li>Docker— orchestrates the 8 services in a single-command local environment</li><li>Terraform (kreuzwerker/docker provider) — declarative infrastructure management; 16 resources across containers, volumes, images, and network; the same stack can be reproduced with terraform apply and torn down with terraform destroy</li><li>DBT — 5 model files (raw → staging → intermediate → mart) replicated across 12 clients = 60 compiled models</li><li>GEval (LLM-as-judge pattern, implemented directly) — 3-metric evaluation harness over 50 golden incidents</li></ul><h3>Introduction</h3><p>The setup I was working with: a hypothetical SaaS analytics company called UDog Analytics that runs identical data pipelines for 12 clients, which I’ll call client_A through client_L. Each client has the same DBT model chain — raw extraction, staging, intermediate transformation, and two mart models — all orchestrated by Apache Airflow running twice daily. When something breaks in that chain, an on-call data engineer has to triage the failure, trace it through the lineage graph, form a hypothesis about the root cause, verify it against run history, write up a fix, and log everything somewhere.</p><p>The challenge was that this triage process was manual, repetitive, and slow. The same five failure patterns — schema changes, null check violations, freshness staleness, timeouts, and upstream dependency cascades — accounted for the vast majority of incidents. Someone had to look at the same Airflow logs, walk the same DBT lineage graph, and write the same fix suggestion dozens of times per month across a dozen clients.</p><p>I wanted to see how far I could push automation on this. Not just alerting — actual investigation. The system I built accepts the Airflow failure webhook, runs a 6-node LangGraph state machine, queries Neo4j for lineage context, pulls 30 days of historical run data from PostgreSQL, calls Claude to generate ranked hypotheses, verifies them with SQL queries, and writes a complete incident report in under 30 seconds.</p><p>The evaluation question was equally interesting: how do you measure whether an AI agent is giving good answers, not just any answers? The answer I landed on was a GEval harness — 50 hand-seeded golden incidents with known root causes and known fixes, evaluated across three Claude-judged metrics.</p><p>Here is what I built, in full detail.</p><h3>Architecture Overview</h3><p>The system runs as 8 Docker Compose services on a shared bridge network called udog_net:</p><ol><li><strong>PostgreSQL 16</strong> (port 5433 external, 5432 internal) — the primary relational store. Holds pipeline run history, task-level results, DBT test results, the lineage node registry, and the incidents table. Both Airflow and the FastAPI backend connect to this instance.</li><li><strong>Neo4j 5 Community</strong> (ports 7474 for browser, 7687 for Bolt) — the graph database holding the DBT lineage. Every DBT model and source for every client is a node. Edges are FEEDS_INTO (direct data flow), DEPENDS_ON (mirrors DBT ref() declarations), and UPSTREAM_OF (derived transitively). The graph has 84 nodes and 216 total edges.</li><li><strong>Airflow webserver</strong> (port 8082) and <strong>Airflow scheduler</strong> — together they run the 12 client DAGs. The webserver is exposed for the UI; the scheduler runs in a separate container sharing the same Airflow metadata database.</li><li><strong>Airflow init </strong>(one-shot container) — runs Alembic migrations on the Airflow metadata database and creates the admin user before the webserver starts.</li><li><strong>FastAPI backend</strong> (port 8001 external) — the investigation engine. Receives webhooks from Airflow, runs the LangGraph state machine synchronously, and serves REST endpoints for querying incidents and lineage. The investigation runs synchronously inside the POST handler — the Airflow on_failure_callback already runs in a subprocess, so blocking it for the 5–30 seconds the investigation takes does not hold up the scheduler. The trade-off is a simpler response contract and easier testing.</li><li><strong>MLflow</strong> (port 5050 external) — tracks every investigation as a run with params, metrics, and 3 artifacts. Uses SQLite as its backend store and a local volume for artifact storage.</li><li><strong>Gradio dashboard</strong> (port 7860) — 4-tab browser UI for browsing incidents, looking up lineage chains, and manually triggering investigations.</li><li><strong>MCP server</strong> (port 8090) — a lightweight FastAPI application that exposes 3 tools following the Model Context Protocol, making the investigation system accessible to Claude Code and similar LLM-powered developer tools.</li></ol><p>The design rationale for this split: I kept Neo4j and PostgreSQL as separate databases rather than picking one for everything. Neo4j is the right tool for multi-hop graph traversal — asking “what are all the ancestors of this model up to 10 hops away” is a single Cypher MATCH with a variable-length path. Doing the same in PostgreSQL requires recursive CTEs that become difficult to reason about at depth. PostgreSQL is the right tool for relational queries: time-series run history, filtered DBT test results, incident indexing. Both databases play to their strengths.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-3izerxFRgRJ_e3XubKrUQ.png" /></figure><h3>Infrastructure as Code with Terraform</h3><p>Beyond Docker Compose, the project includes a complete Terraform configuration in infra/docker/ using the kreuzwerker/docker provider. It manages 16 resources: 5 containers (PostgreSQL, Neo4j, MLflow, Airflow webserver and scheduler), 5 named volumes, 4 pulled images, and 1 bridge network.</p><p>The key design choice was using port offsets (+1 from the docker-compose defaults) so both stacks can run simultaneously on the same machine without conflicts. PostgreSQL gets port 5434 under Terraform vs 5433 under docker-compose; Neo4j browser gets 7475 vs 7474; and so on.</p><p>All sensitive values — passwords, API keys, Fernet keys — are declared as sensitive = true variables and read from terraform.tfvars, which is git-ignored. A terraform.tfvars.example template is committed for reference.</p><p>The full lifecycle is three commands:</p><pre>terraform init<br>terraform plan # previews 16 resources across containers, volumes, images, network<br>terraform apply # provisions all infrastructure<br>terraform destroy</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dOeYHzqPecEZCigEkcnxsQ.png" /></figure><h3>The Data Model</h3><p>PostgreSQL holds 5 tables, all defined as SQLModel classes and migrated via Alembic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/754/1*EMYdxD7s_ET5WFo5Yg9vMg.png" /></figure><p><strong>Pipeline Runs (pipeline_runs):</strong></p><ul><li>id — integer primary key</li><li>dag_id — the Airflow DAG identifier, e.g. client_A__daily_pipeline</li><li>run_id — unique Airflow run identifier string</li><li>client_id — client_A through client_L</li><li>status — enum: success, failed, running, skipped</li><li>triggered_by — enum: schedule, manual, backfill</li><li>started_at, ended_at — timestamps indexed for range queries</li><li>duration_seconds — float, nullable</li></ul><p>This table is the backbone of the PROFILE node. It holds 2,400 seeded rows covering a full calendar year of simulated runs across all 12 clients.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-q_YhgHqDGHROF5hDPqCJw.png" /></figure><p><strong>Pipeline Tasks (pipeline_tasks):</strong></p><ul><li>id, run_id (FK), task_id — the Airflow task identifier</li><li>status — task-level outcome</li><li>started_at, ended_at, duration_seconds</li><li>error_message — raw exception text, up to 4096 characters</li></ul><p><strong>DBT Test Results (dbt_test_results):</strong></p><ul><li>id, run_id (FK), model_name — which DBT model the test ran against</li><li>test_name, test_type — e.g. not_null, accepted_values</li><li>status — enum: pass, fail, warn</li><li>rows_failed — integer count of failing rows</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dSd0tI4srWZLKaZTaVCpig.png" /></figure><p><strong>Lineage Nodes (lineage_nodes):</strong></p><ul><li>id, node_name (unique), node_type — Source, Model, or Metric</li><li>client_id, owner_team, layer — raw, staging, intermediate, or mart</li><li>last_run_status, neo4j_node_id — bridge to the graph database</li></ul><p><strong>Incidents (incidents):</strong></p><ul><li>id, run_id (FK), triggered_at</li><li>affected_models — JSON array of model name strings</li><li>root_cause_type — enum with 8 values: schema_change, null_check, freshness, timeout, dependency_failure, data_volume, flaky, unknown</li><li>root_cause_summary, fix_suggestion — text fields</li><li>hypothesis_chain — JSON array of the full LangGraph hypothesis objects</li><li>severity — P1, P2, or P3</li><li>status — open, investigating, resolved, false_positive</li><li>mlflow_run_id — links back to the MLflow tracking server</li><li>similar_incident_ids — JSON array of related incident IDs for deduplication</li></ul><p>The incidents table serves double duty: it is the operational output of the agent (every investigation writes a row here) and the ground truth store for the GEval evaluation harness (50 seeded rows with known root causes).</p><h3>Building the Lineage Graph in Neo4j</h3><p>The DBT project has 5 model files per client: raw_orders (Source), raw_inventory (Source), stg_orders, stg_inventory, int_order_fulfillment, mart_daily_revenue, and mart_inventory_health. With 12 clients that comes to 84 nodes and, across FEEDS_INTO, DEPENDS_ON, and transitively-derived UPSTREAM_OF edges, 216 total relationships.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6x3wHhlMY6LRZI8vbkut6w.png" /></figure><p>The loader (lineage/loader.py) runs at startup and is idempotent — it uses Cypher MERGE statements so re-running it never creates duplicates. The edge structure per client looks like this:</p><p>raw_orders → stg_orders → int_order_fulfillment → mart_daily_revenue</p><p>raw_inventory → stg_inventory → int_order_fulfillment</p><p>raw_inventory → stg_inventory → mart_inventory_health</p><p>The primary Cypher query the TRAVERSE node uses to walk upstream from any failed model is:</p><pre>MATCH path = (ancestor)-[:FEEDS_INTO*1..10]-&gt;(target {name: $name})<br>RETURN<br>ancestor.name AS name,<br>labels(ancestor)[0] AS node_type,<br>ancestor.layer AS layer,<br>ancestor.client_id AS client_id,<br>length(path) AS depth<br>ORDER BY depth ASC</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tWLNGY3yfXkhoIpOvqkB6A.png" /></figure><p>This returns every ancestor node ordered by hop distance. A failure on mart_daily_revenue returns int_order_fulfillment at depth 1, stg_orders and stg_inventory at depth 2, and raw_orders and raw_inventory at depth 3. The TRAVERSE node also runs the inverse query to get the downstream blast radius — any model that depends on the failed model and would be stale once it fails.</p><p>The loader also derives UPSTREAM_OF edges transitively using a second Cypher pass:</p><pre>MATCH path = (upstream {client_id: $client_id})-[:FEEDS_INTO*2..]-&gt;(downstream {client_id: $client_id})<br>MERGE (upstream)-[:UPSTREAM_OF]-&gt;(downstream)</pre><p>This pre-materialises multi-hop ancestor relationships so some queries can use the direct UPSTREAM_OF edge instead of a variable-length path scan.</p><h3>The LangGraph Investigation Agent-All 6 Nodes in Detail</h3><p>The agent is implemented as a LangGraph StateGraph. Every node receives the full AgentState TypedDict and returns an updated copy. The state flows linearly from TRIAGE through VERIFY, then branches conditionally: if the confidence threshold is met, the graph routes to REPORT; if not, it routes to CLARIFY.</p><p>The AgentState type definition:</p><pre>class Hypothesis(TypedDict):<br>    rank: int<br>    hypothesis: str<br>    confidence: float<br>    verified: bool<br>    evidence_query: str | None<br>    class AgentState(TypedDict, total=False):<br>    # Webhook input<br>    run_id: str<br>    dag_id: str<br>    client_id: str<br>    failed_task_id: str<br>    exception_message: str<br>    # TRIAGE output<br>    error_type: str<br>    severity: str # P1 / P2 / P3<br>    # TRAVERSE output<br>    lineage_chain: list[dict[str, Any]]<br>    affected_models: list[str]<br>    # PROFILE output<br>    run_history: list[dict[str, Any]]<br>    dbt_failures: list[dict[str, Any]]<br>    # HYPOTHESIZE output<br>    hypotheses: list[Hypothesis]<br>    # VERIFY output<br>    verified_cause: str<br>    fix_suggestion: str<br>    # REPORT output<br>    incident_id: int | None<br>    mlflow_run_id: str | None<br>    # CLARIFY branch<br>    needs_clarification: bool<br>    clarification_msg: str</pre><h4>Node 1: TRIAGE</h4><p>TRIAGE is a pure Python function with no external I/O — it runs in microseconds. Its job is to classify the exception message into one of 5 error types and assign a P1/P2/P3 severity.</p><p>Classification uses a list of compiled regex patterns evaluated in order. The first match wins:</p><pre>_ERROR_PATTERNS: list[tuple[re.Pattern, str]] = [<br>(re.compile(r&quot;schema|column.*(not found|missing|mismatch)&quot;, re.I), &quot;schema_change&quot;),<br>(re.compile(r&quot;not.null|null.*check|null.*value&quot;, re.I), &quot;null_check&quot;),<br>(re.compile(r&quot;fresh|stale|sla|last.*loaded&quot;, re.I), &quot;freshness&quot;),<br>(re.compile(r&quot;timeout|timed out|exceed.*limit|pool.*exhaust&quot;, re.I), &quot;timeout&quot;),<br>(re.compile(r&quot;upstream.*fail|dependent.*not.*material|cannot execute&quot;, re.I), &quot;dependency&quot;),<br>]<br>_MART_TASKS = {&quot;run_mart_daily_revenue&quot;, &quot;run_mart_inventory_health&quot;}<br>_INT_TASKS = {&quot;run_int_order_fulfillment&quot;}<br>def triage_node(state: AgentState) -&gt; AgentState:<br>  exception_msg = state.get(&quot;exception_message&quot;, &quot;&quot;)<br>  failed_task_id = state.get(&quot;failed_task_id&quot;, &quot;&quot;)<br>  error_type = _classify_error(exception_msg)<br>  severity = _assign_severity(error_type, failed_task_id)<br>  return {**state, &quot;error_type&quot;: error_type, &quot;severity&quot;: severity}</pre><p>Severity rules: P1 for schema_change or freshness failures in the mart layer (these directly break client-facing reports). P2 for null_check or dependency failures in intermediate or mart. P3 for everything else — timeouts, unknowns, and staging failures that have not yet propagated downstream.</p><h4>Node 2: TRAVERSE</h4><p>TRAVERSE queries Neo4j to populate two fields: lineage_chain (the upstream ancestors of the failed model) and affected_models (the downstream blast radius). It derives the DBT model name from the Airflow task ID by stripping the “run_” prefix and prepending the client ID. A failure on task run_mart_daily_revenue for client_A maps to model client_A__mart_daily_revenue.</p><p>The node calls three lineage queries in sequence: get_upstream_chain to walk ancestors, get_downstream_impact to enumerate affected models, and find_root_failure to identify the topmost node in the affected set that has no upstream predecessors among the failed models. Root failure nodes are prepended to the lineage chain so Claude has them at the top of its context window in HYPOTHESIZE.</p><h4>Node 3: PROFILE</h4><p>PROFILE pulls two data sets from PostgreSQL using synchronous SQLModel sessions: the past 30 days of pipeline_run records for the failing client (up to 200 rows), and all dbt_test_result rows with status FAIL for the affected models within the same 30-day window (up to 100 rows).</p><p>These two data sets give Claude the statistical context it needs: how often has this client’s pipeline failed recently, and which DBT data quality tests have been failing on the models in the blast radius? The PROFILE node computes total_runs, failed_runs, and failure_rate from the run history and passes these as summary statistics into the HYPOTHESIZE prompt.</p><h4>Node 4: HYPOTHESIZE</h4><p>HYPOTHESIZE is where the LLM reasoning happens. It constructs a structured prompt from all prior node outputs and calls Claude claude-sonnet-4–6. The prompt instructs Claude to respond with a JSON array only — no prose — and to rank hypotheses by confidence descending.</p><pre>def hypothesize_node(state: AgentState) -&gt; AgentState:<br>      run_history  = state.get(&quot;run_history&quot;, [])<br>      total_runs   = len(run_history)<br>      failed_runs  = sum(1 for r in run_history if r.get(&quot;status&quot;) == &quot;failed&quot;)<br>      failure_rate = failed_runs / total_runs if total_runs else 0.0<br><br>      prompt = _USER_TEMPLATE.format(<br>          max_hypotheses=settings.agent_max_hypotheses,<br>          dag_id=state.get(&quot;dag_id&quot;, &quot;&quot;),<br>          client_id=state.get(&quot;client_id&quot;, &quot;&quot;),<br>          failed_task_id=state.get(&quot;failed_task_id&quot;, &quot;&quot;),<br>          error_type=state.get(&quot;error_type&quot;, &quot;unknown&quot;),<br>          severity=state.get(&quot;severity&quot;, &quot;P3&quot;),<br>          exception_message=state.get(&quot;exception_message&quot;, &quot;&quot;),<br>          lineage_chain=json.dumps(state.get(&quot;lineage_chain&quot;, []), indent=2),<br>          affected_models=json.dumps(state.get(&quot;affected_models&quot;, []), indent=2),<br>          dbt_failures=json.dumps(state.get(&quot;dbt_failures&quot;, [])[:10], indent=2),<br>          total_runs=total_runs,<br>          failed_runs=failed_runs,<br>          failure_rate=failure_rate,<br>      )<br><br>      message = _client.messages.create(<br>          model=settings.anthropic_model,<br>          max_tokens=1024,<br>          system=_SYSTEM_PROMPT,<br>          messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}],<br>      )<br><br>      raw = message.content[0].text.strip()<br>      # Strip markdown code fences if Claude wrapped the JSON<br>      if raw.startswith(&quot;```&quot;):<br>          raw = raw.split(&quot;```&quot;, 2)[1]<br>          if raw.startswith(&quot;json&quot;):<br>              raw = raw[4:]<br>          raw = raw.strip()<br><br>      hypotheses: list[Hypothesis] = json.loads(raw)<br>      for h in hypotheses:<br>          h.setdefault(&quot;verified&quot;, False)<br>          h.setdefault(&quot;evidence_query&quot;, None)<br><br>      return {**state, &quot;hypotheses&quot;: hypotheses}</pre><p>The system prompt sets Claude’s persona as a senior data reliability engineer at UDog Analytics and instructs it to respond with valid JSON only. The markdown fence stripping handles a common Claude behaviour where it wraps JSON responses in triple-backtick blocks even when told not to — this was particularly necessary when using Claude Haiku as the GEval judge model.</p><p>Each hypothesis in the returned array has a rank, a hypothesis string, a confidence float between 0 and 1, a verified boolean (initially false), and an evidence_query field — either null or a SQL SELECT statement that VERIFY can run to confirm the hypothesis.</p><h4>Node 5: VERIFY</h4><p>VERIFY iterates through the hypotheses and executes any non-null evidence_query against PostgreSQL. Before executing any query, it runs it through _is_safe_query(), which enforces that only SELECT statements are permitted.</p><pre>_SELECT_ONLY = re.compile(r&quot;^\s*SELECT\b&quot;, re.I)<br>  _CONFIDENCE_THRESHOLD = 0.55<br><br>  def _is_safe_query(query: str) -&gt; bool:<br>      return bool(_SELECT_ONLY.match(query.strip()))<br><br>  def verify_node(state: AgentState) -&gt; AgentState:<br>      hypotheses: list[Hypothesis] = state.get(&quot;hypotheses&quot;, [])<br>      updated: list[Hypothesis] = []<br><br>      with Session(_sync_engine) as session:<br>          for h in hypotheses:<br>              query = h.get(&quot;evidence_query&quot;)<br>              if query:<br>                  evidence = _run_evidence_query(query, session)<br>                  h = dict(h)<br>                  h[&quot;verified&quot;] = bool(evidence and &quot;error&quot; not in evidence)<br>              updated.append(h)<br><br>      verified_cause, confidence = _pick_verified_cause(updated)<br>      needs_clarification = confidence &lt; _CONFIDENCE_THRESHOLD<br>      fix_suggestion = _build_fix_suggestion(verified_cause, state)<br><br>      return {<br>          **state,<br>          &quot;hypotheses&quot;: updated,<br>          &quot;verified_cause&quot;: verified_cause,<br>          &quot;fix_suggestion&quot;: fix_suggestion,<br>          &quot;needs_clarification&quot;: needs_clarification,<br>      }</pre><p>The confidence threshold is 0.55. If the best hypothesis after SQL verification still has confidence below that value, needs_clarification is set to True and the graph routes to CLARIFY instead of REPORT. Fix suggestions are generated from a template dictionary keyed on the verified_cause string — for example, a schema_change fix suggests updating the failing model’s SQL to align with the new upstream schema and re-running the staging layer.</p><h4>Node 6: REPORT</h4><p>REPORT writes the complete incident record to the incidents table in PostgreSQL. It maps the severity string (P1/P2/P3) and verified_cause string to their enum equivalents, builds a human-readable root_cause_summary string, and persists the full hypothesis chain as a JSON column.</p><p>The REPORT node also links the incident to its MLflow run via the mlflow_run_id field on the AgentState, which was set by the FastAPI webhook handler before invoking the graph. An important implementation detail: when the webhook receives a simulated or new run_id that does not exist in pipeline_runs, a helper function _ensure_pipeline_run() creates that record first. Without this, the _resolve_run_id lookup in REPORT silently falls back to run_id=1, causing GET /investigations/{run_id} to return 404 for any non-seeded investigation.</p><h4>The CLARIFY Node</h4><p>CLARIFY is reached when confidence is too low to commit to a root cause. It extracts the top hypothesis by confidence, formats a clarification message including the hypothesis text and confidence percentage, and writes it to state. The graph terminates after CLARIFY — no incident row is written. The FastAPI response signals status: “clarification_needed” back to the Airflow callback, and the Gradio dashboard surfaces the clarification message for the on-call engineer to act on.</p><h3>The LangGraph Graph Assembly</h3><p>The graph wires the 6 nodes with linear edges for the happy path and a conditional branch after VERIFY:</p><pre>def _route_after_verify(state: AgentState) -&gt; str:<br>      if state.get(&quot;needs_clarification&quot;, False):<br>          return &quot;clarify&quot;<br>      return &quot;report&quot;<br><br>  def build_investigation_graph() -&gt; StateGraph:<br>      graph = StateGraph(AgentState)<br>      graph.add_node(&quot;triage&quot;,      triage_node)<br>      graph.add_node(&quot;traverse&quot;,    traverse_node)<br>      graph.add_node(&quot;profile&quot;,     profile_node)<br>      graph.add_node(&quot;hypothesize&quot;, hypothesize_node)<br>      graph.add_node(&quot;verify&quot;,      verify_node_with_mlflow)<br>      graph.add_node(&quot;report&quot;,      report_node)<br>      graph.add_node(&quot;clarify&quot;,     clarify_node)<br><br>      graph.add_edge(START, &quot;triage&quot;)<br>      graph.add_edge(&quot;triage&quot;,      &quot;traverse&quot;)<br>      graph.add_edge(&quot;traverse&quot;,    &quot;profile&quot;)<br>      graph.add_edge(&quot;profile&quot;,     &quot;hypothesize&quot;)<br>      graph.add_edge(&quot;hypothesize&quot;, &quot;verify&quot;)<br><br>      graph.add_conditional_edges(<br>          &quot;verify&quot;,<br>          _route_after_verify,<br>          {&quot;report&quot;: &quot;report&quot;, &quot;clarify&quot;: &quot;clarify&quot;},<br>      )<br><br>      graph.add_edge(&quot;report&quot;,  END)<br>      graph.add_edge(&quot;clarify&quot;, END)<br>      return graph.compile()<br><br>  investigation_graph = build_investigation_graph()</pre><p>The compiled graph is a module-level singleton. The FastAPI webhook handler imports it directly and calls investigation_graph.invoke(initial_state) — no instantiation overhead per request.</p><h3>The FastAPI Backend</h3><p>The webhook receiver at POST /webhook/airflow is the entry point for all investigations. It accepts the AirflowWebhookPayload, ensures the pipeline run record exists in PostgreSQL, constructs the initial AgentState, starts an MLflow run via InvestigationTracker, invokes the LangGraph graph synchronously, logs the final state to MLflow, and returns the WebhookResponse. The route returns HTTP 202 Accepted to signal that the work has been acknowledged, but the investigation itself completes before the response is sent.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rbbqbOvh6sBPEGurss4F2w.png" /></figure><p>The Airflow on_failure_callback that fires the webhook:</p><pre>def _fire_webhook(context: dict) -&gt; None:<br>      ti = context[&quot;task_instance&quot;]<br>      payload = {<br>          &quot;dag_id&quot;:          context[&quot;dag&quot;].dag_id,<br>          &quot;run_id&quot;:          context[&quot;run_id&quot;],<br>          &quot;client_id&quot;:       context[&quot;dag&quot;].dag_id.split(&quot;__&quot;)[0],<br>          &quot;task_id&quot;:         ti.task_id,<br>          &quot;execution_date&quot;:  context[&quot;execution_date&quot;].isoformat(),<br>          &quot;exception&quot;:       str(context.get(&quot;exception&quot;, &quot;&quot;)),<br>          &quot;log_url&quot;:         ti.log_url,<br>      }<br>      try:<br>          resp = requests.post(<br>              f&quot;{BACKEND_URL}/webhook/airflow&quot;,<br>              json=payload,<br>              headers={&quot;X-API-Key&quot;: API_KEY},<br>              timeout=10,<br>          )<br>          resp.raise_for_status()<br>          log.info(&quot;Webhook fired successfully: %s&quot;, resp.json())<br>      except requests.RequestException as exc:<br>          log.error(&quot;Failed to fire investigation webhook: %s&quot;, exc)</pre><p>The client_id is derived by splitting the DAG ID on the double underscore separator — client_A__daily_pipeline becomes client_A. This convention is baked into the DAG factory that generates all 12 DAGs from a single _make_dag(client_id) function.</p><p>Authentication uses a shared API key passed as the X-API-Key header. The FastAPI deps.py verifies this on every route. The key is mounted from a .env file via pydantic-settings. No secrets are hardcoded anywhere in the source.</p><p>Additional REST routes handle querying: GET /investigations/ (list with client_id and days filters), GET /investigations/{run_id} (fetch by Airflow run ID), and GET /lineage/{model_name}/upstream|downstream|subgraph.</p><h3>MLflow Observability</h3><p>Every investigation produces one MLflow run with the following structure:</p><h4>Params (logged at run start and updated after triage):</h4><p>• run_id, dag_id, client_id — webhook identifiers</p><p>• error_type, severity, verified_cause — set after the relevant nodes complete</p><h4>Metrics:</h4><p>• lineage_depth — number of nodes in the upstream lineage chain</p><p>• hypotheses_generated — count of hypotheses Claude produced</p><p>• hypothesis_confidence_max — highest confidence value across all hypotheses</p><p>• investigation_duration_s — wall-clock time from MLflow run start to log_final_state call</p><h4>Artifacts (3 files logged to the run):</h4><ol><li>full_incident_report.json — a flattened serialisation of the complete AgentState: all lineage nodes, the full run history summary, every hypothesis, the verified cause, and the fix suggestion. This is the primary debugging artifact.</li><li>hypothesis_chain.json — just the hypotheses list, making it easy to compare hypothesis quality across different incident types without loading the full report.</li><li>lineage_subgraph.graphml — a NetworkX directed graph of the lineage chain and affected models exported in GraphML format. This can be loaded directly into Gephi or any other graph analysis tool for visual exploration.</li></ol><p>Using MLflow for agent observability rather than just for ML model training turned out to be a natural fit. The params/metrics/artifacts structure maps cleanly onto an investigation: params are the context the agent received, metrics quantify the depth and quality of the investigation, and artifacts are the structured outputs. The MLflow UI gives you a dashboard where you can sort investigations by hypothesis_confidence_max to find the uncertain cases, or by investigation_duration_s to find the slow ones.</p><p>The evaluation harness logs its results to a separate geval-evaluation experiment in the same MLflow instance, so you can compare individual investigation quality against aggregate evaluation metrics in one place.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Dv2ebNh9Xu5vyLiVnO_q1g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*I6zP0q0icAS2H5VB9jjJXA.png" /></figure><h3>The Gradio Dashboard</h3><p>The Gradio dashboard has 4 tabs:</p><p><strong>Tab 1:</strong> Recent Incidents. A dropdown to select a client (or “All clients”) and a day-range slider from 1 to 30. On refresh, it fetches up to 50 incidents from the backend and renders a severity-tagged summary list. The first 5 incidents are also shown as raw JSON in a code block for debugging.</p><p><strong>Tab 2:</strong> Incident Detail. A text input for an Airflow run_id. On fetch, it renders a full incident report including severity, root cause type, affected models, root cause summary, and fix suggestion. Below it shows the hypothesis chain as JSON — the ranked list of hypotheses Claude generated, with their confidence scores and verification status.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*e6UQ9OrwXKRygYt5U9Ixvg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6qTA56csJ0KB7dfHyHderg.png" /></figure><p><strong>Tab 3:</strong> Lineage Lookup. A model name input (e.g. client_A__mart_daily_revenue), a direction dropdown (upstream ancestors, downstream blast radius, or full subgraph), and a depth slider. Returns the lineage chain as JSON, which is useful for ad hoc lineage exploration without opening the Neo4j browser.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JCwxqSqX4uLp168QIAslEg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YzkOgoqpLyRAYu8pboQi-A.png" /></figure><p><strong>Tab 4:</strong> Trigger Investigation. A manual investigation trigger for testing and demonstration. Select a client, an error type, and a failing task, then click the button. The dashboard generates a synthetic run_id, fires the webhook, and shows the response including the incident ID and MLflow run ID.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*e2qN_DTBCItzy92Rlc03xQ.png" /></figure><h3>The MCP Server</h3><p>MCP stands for Model Context Protocol — a standard for exposing tools to LLM-powered developer assistants in a way they can discover and invoke programmatically. The idea is that instead of writing a one-off script to query your investigation system, you register it as an MCP tool and any compatible assistant (Claude Code, for example) can call it directly from a conversation.</p><p>For data engineers this is significant. It means Claude Code can answer questions like “what failed in client_B’s pipeline yesterday?” or “show me the upstream lineage for mart_daily_revenue” without any copy-pasting of run IDs or manually navigating the Gradio UI. The engineer describes what they want; the assistant calls the right tool and surfaces the structured result.</p><p>The MCP server exposes 3 tools at port 8090:</p><p><strong>Tool 1:</strong> investigate. Takes a run_id and optionally client_id, dag_id, task_id, and exception. First checks whether an investigation for this run_id already exists in the backend (GET /investigations/{run_id}). If it does, returns the cached report. If not, and if client_id and dag_id are provided, fires a synthetic webhook to trigger a new investigation. This tool is idempotent — calling it twice on the same run_id returns the same report.</p><p><strong>Tool 2:</strong> lineage_lookup. Takes a model_name, a direction (upstream, downstream, or subgraph), and a depth. Proxies to the corresponding FastAPI lineage route. Returns the full lineage chain as a JSON object that the LLM can reason about inline.</p><p><strong>Tool 3:</strong> incident_history. Takes a client_id, a days lookback window, and a result limit. Returns recent incidents for the client filtered by the time window. Useful for patterns like “has client_F had more schema_change failures than usual this week?”</p><p>All three tools are thin proxies over the FastAPI backend — they add no logic of their own, just translate MCP-format requests into backend HTTP calls and return the results. The MCP manifest is served at the root GET / endpoint and declares the tool schemas in JSON Schema format.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z6PuOeULi7pAoLwMWaVA9Q.png" /></figure><h3>GEval Evaluation Harness</h3><p>The hardest part of building an AI agent is not making it produce output — it is making it produce correct output, and knowing when it does not. For a data reliability agent, correctness means: did it identify the right root cause, did it trace the lineage correctly, and did it suggest a useful fix?</p><p>I used the GEval pattern — LLM-as-judge evaluation — implemented directly rather than through a third-party library. The harness lives in backend/app/evaluation/harness.py and runs against 50 golden incidents seeded into the PostgreSQL incidents table via <strong>scripts/seed.py</strong>. Each golden incident has a known root_cause_type, a known list of affected models (the first entry being the true root model), and a known fix_suggestion written by a human.</p><p>The 50 incidents are distributed across all 5 failure patterns (schema_change, null_check, freshness, timeout, dependency), all 12 clients, and both mart-layer and intermediate-layer failures, giving a representative sample of the full failure space the agent encounters.</p><p>To reduce evaluation cost, the LLM judge uses Claude Haiku (claude-haiku-4–5–20251001) rather than Sonnet. The judge is called once per incident for the Fix Relevance metric; Root Cause Accuracy and Lineage Correctness are computed without LLM calls. Running 50 cases with the Haiku judge costs approximately $0.50.</p><p>Three primary metrics govern whether the harness passes:</p><p><strong>Metric 1:</strong> Root Cause Accuracy (target ≥ 80%). This is an exact string match between the agent’s verified_cause field and the golden ground truth root_cause_type. There is no partial credit — if the agent returns “timeout” for a “schema_change” incident, it counts as a miss. This is the strictest metric and the hardest to game, because the classification taxonomy is closed: 8 possible values, one correct answer per incident.</p><p><strong>Metric 2:</strong> Lineage Correctness (target ≥ 90%). This checks whether the true root model name (the first entry in the golden incident’s affected_models list) appears anywhere in the agent’s lineage_chain output. The match is a substring check to handle naming variations. This is more lenient than Root Cause Accuracy because it only requires that the lineage traversal reached the right part of the graph, not that it identified the precise cause.</p><p><strong>Metric 3:</strong> Fix Relevance (target ≥ 75%). This is the LLM-as-judge metric. Claude Haiku is given the agent’s fix_suggestion and the golden fix_suggestion side by side and asked to score the agent’s fix from 0.0 to 1.0 on relevance and correctness. The prompt asks Claude to respond with a JSON object containing a score and a brief reason. The average of these scores across all 50 incidents must reach 75%. This metric captures whether the fix is actionable, not just whether it mentions the right error type.</p><p>Results are logged to the geval-evaluation MLflow experiment after every harness run, making it straightforward to track evaluation quality across prompt iterations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qPQ9KcAECVKwi4LAsPc4Jg.png" /></figure><h3>Unit Tests</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TV79ho5Ga3ezgoQfIO1QGQ.png" /></figure><h3>Integration Tests</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1UeVjGvkyDm56je_cWnhCw.png" /></figure><h3>Results and Lessons</h3><p>The system does what it was designed to do: take a structured failure signal from Airflow and produce a structured root cause report without human intervention, in under 30 seconds, with traceable reasoning.</p><p>A few things I would do differently:</p><p>The synchronous webhook handler keeps the response contract simple but means the Airflow callback waits for the full investigation. For a production deployment where investigations routinely exceed 30 seconds or where multiple failures arrive simultaneously, moving to a background task queue (Celery or similar) with a polling endpoint would be the right call.</p><p>The VERIFY node’s evidence queries are generated by Claude against a database schema it can’t see directly — it infers column names from the hypothesis context. This works well for common patterns but fails on queries that reference columns that exist in a different form than Claude expects. Passing an explicit schema summary in the HYPOTHESIZE prompt would improve verification accuracy.</p><p>The Terraform configuration covers the infrastructure services but not the custom-built containers (backend, Gradio, MCP server), since those require docker build context. A complete Terraform solution would use the docker_registry_image resource to build and push images to a local registry first. For this project, the split is clean: Terraform owns the data infrastructure, Docker Compose owns the full application stack.</p><p>The GEval harness runs 50 cases in a single synchronous pass. Parallelising across incidents with a thread pool would cut evaluation wall-clock time by 5–10x.</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=17b4842f0286" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI End to End Project — Create a Chatbot Using LangGraph, LangChain, Tavily, SQLite]]></title>
            <link>https://medium.com/@dogukannulu/ai-end-to-end-project-create-a-chatbot-using-langgraph-langchain-tavily-sqlite-b9aeada69043?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/b9aeada69043</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[chatbots]]></category>
            <category><![CDATA[tavily]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Sun, 05 Jul 2026 14:51:59 GMT</pubDate>
            <atom:updated>2026-07-05T14:51:59.591Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/601/1*XSWSNOyDqJ4WFZhje5hCSA.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/langgraph-chatbot-tavily-sqlite">https://github.com/dogukannulu/langgraph-chatbot-tavily-sqlite</a></p><h3>Tech Stack</h3><ul><li>LangChain</li><li>LangGraph</li><li>Tavily Search</li><li>SQLite</li><li>Black</li><li>Google Gemini</li></ul><h3>Overview</h3><ul><li>As the beginning of this project, we are going first to leverage uv and Makefile to automate processes.</li><li>Then, create a virtual environment and install all requirements.</li><li>Format all scripts with black.</li><li>Create and run environment test script.</li><li>Create and run demo script to call the model.</li><li>Then, we will create LangGraph graph that will work as a chatbot with tool calling.</li><li>We will create nodes, edges and will use conditional routing.</li><li>We will also keep chat memory using SQLite and keep threads IDs there.</li></ul><h3>Introduction</h3><p>In this comprehensive guide, we’ll build a sophisticated chatbot using LangGraph that features persistent memory, tool integration, and routing capabilities. Our chatbot will use Google’s Gemini model, integrate with Tavily for web search, maintain conversation history in SQLite, and provide a polished user experience.</p><h3>Prerequisites and Tools Overview</h3><h3>What is UV?</h3><p>UV is a modern Python package manager that’s significantly faster than pip. Developed by the creators of Ruff, UV can be 10–100x faster than traditional pip installations. Key benefits include:</p><ul><li><strong>Speed</strong>: Ultra-fast package resolution and installation</li><li><strong>Reliability</strong>: Better dependency resolution than pip</li><li><strong>Modern</strong>: Built with Rust for performance</li><li><strong>Compatible</strong>: Drop-in replacement for pip in most cases</li></ul><p>UV handles virtual environments, package installation, and dependency management more efficiently than traditional Python tools. We are going to use it for package installation, venv creation and script running.</p><p>You may find more information in <a href="https://github.com/astral-sh/uv">this repo</a>.</p><h3>What is a Makefile?</h3><p>A Makefile is a file containing a set of directives used by the make utility to automate tasks. In Python projects, Makefiles help standardize common development tasks:</p><ul><li><strong>Task Automation</strong>: Run complex commands with simple make commands</li><li><strong>Consistency</strong>: Ensure all developers use the same commands</li><li><strong>Documentation</strong>: Serve as living documentation of common tasks</li><li><strong>Cross-platform</strong>: Work across different operating systems</li></ul><p>Example: Instead of remembering uv pip install -r requirements.txt, we will simply run make install.</p><h3>Core Technologies Overview</h3><h4>LangChain</h4><p>LangChain is a framework for developing applications powered by language models. It provides:</p><ul><li><strong>Chains</strong>: Link multiple LLM calls together</li><li><strong>Memory</strong>: Maintain conversation context</li><li><strong>Tools</strong>: Integrate external APIs and services</li><li><strong>Agents</strong>: Create autonomous decision-making systems</li></ul><h4>LangGraph</h4><p>LangGraph is LangChain’s library for building stateful, multi-actor applications with LLMs. Key features:</p><ul><li><strong>Graph-based Architecture</strong>: Define workflows as directed graphs</li><li><strong>State Management</strong>: Persistent state across conversation turns</li><li><strong>Conditional Routing</strong>: Dynamic flow control based on conditions</li><li><strong>Checkpoints</strong>: Save and restore conversation state</li></ul><h4>Tavily Search</h4><p>Tavily is a search API designed specifically for AI applications:</p><ul><li><strong>AI-Optimized</strong>: Results formatted for LLM consumption</li><li><strong>Real-time</strong>: Access to current web information</li><li><strong>Reliable</strong>: High availability and performance</li><li><strong>Easy Integration</strong>: Simple API with LangChain support</li></ul><p>Now, we can start implementing each step of the project.</p><h3>Get API Keys</h3><h3>Google API Key (Gemini)</h3><ol><li><strong>Visit Google AI Studio</strong>: Go to <a href="https://aistudio.google.com/app/apikey">https://aistudio.google.com/app/apikey</a></li><li><strong>Sign in</strong>: Use your Google account</li><li><strong>Create API Key</strong>: Click “Create API Key”</li><li><strong>Copy Key</strong>: Save the generated key securely in your local</li><li><strong>Set Billing</strong>: Ensure billing is enabled for your Google Cloud project</li></ol><h3>Tavily API Key</h3><ol><li><strong>Visit Tavily</strong>: Go to <a href="https://tavily.com/">https://tavily.com/</a></li><li><strong>Sign Up</strong>: Create an account</li><li><strong>Dashboard</strong>: Access your dashboard after verification</li><li><strong>API Keys</strong>: Navigate to the API keys section</li><li><strong>Generate Key</strong>: Create a new API key and save it securely in your local</li><li><strong>Free Tier</strong>: Tavily offers free searches per month</li></ol><h3>Project Setup</h3><h3>Directory Structure</h3><p>Let’s start by understanding our project structure:</p><pre>advanced_chatbot/<br>├── main.py                          # Main application<br>├── requirements.txt                 # Dependencies<br>├── env_template.txt                # Environment template<br>├── test_development_setup.py       # Setup verification<br>├── demo_chatbot.py                 # Demo script<br>├── Makefile                        # Task automation<br>├── README.md                       # Documentation<br>├── chatbot.log                     # Generated logs<br>└── chatbot_checkpoint.db           # Generated database</pre><h3>Installation Process with Makefile</h3><p>Our project includes a comprehensive Makefile that automates all setup and development tasks. You may run make help to see all commands it has by running make help. Let’s go through the complete setup process:</p><h4>Step 1: Install UV Package Manager</h4><p>You may refer to Makefile <a href="https://github.com/dogukannulu/langgraph-chatbot-tavily-and-chat-memory-sqllite/blob/main/Makefile">here</a>.</p><pre># Use Makefile to install UV automatically<br>make install-uv</pre><p>This command:</p><ul><li>Checks if UV is already installed</li><li>Installs UV using Homebrew if available</li><li>Provides instructions if Homebrew is not found</li><li>Confirms successful installation with version info</li></ul><h4>Step 2: Create Virtual Environment</h4><pre># Create virtual environment named &#39;chatbot&#39;<br>make create-venv</pre><p>This creates a new virtual environment called chatbot using UV, which is much faster than traditional python -m venv.</p><h4>Step 3: Activate Virtual Environment</h4><pre># Activate the virtual environment (this step cannot be automated by Makefile)<br>source chatbot/bin/activate</pre><p><strong>Note</strong>: Makefile cannot activate virtual environments directly due to shell limitations, so this must be done manually.</p><h4>Step 4: Install Dependencies</h4><pre># Install all project dependencies in requirements.txt<br>make install</pre><p>This command:</p><ul><li>Checks if virtual environment is activated</li><li>Uses UV if available (much faster), falls back to pip</li><li>Installs all dependencies from requirements.txt</li><li>Provides helpful error messages if environment isn’t activated</li></ul><h4>Step 5: Configure Environment Variables</h4><p>Create a .env file from the template:</p><pre>cp env_template.txt .env</pre><p>Edit .env with your API keys:</p><pre># Google API Configuration<br>GOOGLE_API_KEY=your_actual_google_api_key_here<br># Tavily Search API Configuration  <br>TAVILY_API_KEY=your_actual_tavily_api_key_here<br># Optional configurations<br>CHECKPOINT_DB_PATH=chatbot_checkpoint.db<br>LOG_LEVEL=INFO<br>LOG_FILE=chatbot.log</pre><h3>Dependencies Analysis</h3><p>Let’s examine our requirements.txt:</p><pre># Core LangChain and LangGraph dependencies<br>langchain&gt;=0.3.0                    # Main framework<br>langchain-google-genai&gt;=2.0.0       # Google Gemini integration<br>langchain-community&gt;=0.3.0          # Community tools<br>langgraph&gt;=0.2.0                    # Graph workflows<br>langgraph-checkpoint-sqlite&gt;=0.1.0  # SQLite persistence<br># Search and web tools<br>langchain-tavily&gt;=0.1.0             # Web search<br># Environment and configuration<br>python-dotenv&gt;=1.0.0                # Environment variables<br># Additional utilities<br>beautifulsoup4&gt;=4.12.0              # HTML parsing<br>requests&gt;=2.31.0                    # HTTP requests<br># Development tools<br>black&gt;=24.0.0                       # Code formatting</pre><h3>Development Workflow with Makefile</h3><p>As the next step, you may create your own project files by obtaining them from the repo. Now, we are going to format all the scripts existing in our project.</p><h4>Install Development Tools</h4><pre># Install development dependencies (to format all our scripts)<br>make dev-install</pre><p>This installs additional tool black for code formatting.</p><h4>Code Formatting</h4><pre># Auto-format all Python code with black<br>make format</pre><h4>Testing Setup</h4><pre># Run setup verification tests<br>make test</pre><p>This runs test_development_setup.py which verifies:</p><ul><li>All imports work correctly</li><li>Environment configuration is proper</li><li>Database connections function</li><li>Main script components load successfully</li></ul><p>So far, we created and activated our virtual environment, installed all requirements, formatted our code and tested imports. Now, we will move on with main chatbot application with Langchain/LangGraph</p><h3>Core Implementation</h3><h3>State Management Design</h3><p>Our chatbot uses a TypedDict to define its state structure. We are going to use this class for the state structure of our chatbot.</p><pre>from typing import Annotated, Dict, Any<br>from typing_extensions import TypedDict<br>from langgraph.graph.message import add_messages<br><br>class State(TypedDict):<br>    &quot;&quot;&quot;<br>    State structure for our chatbot.<br>    <br>    Key Features:<br>    - messages: Conversation history with automatic message addition<br>    - session_info: Metadata and statistics<br>    - thread_id: Unique identifier for memory persistence<br>    &quot;&quot;&quot;<br>    messages: Annotated[list, add_messages]  # Auto-manages message history<br>    session_info: Dict[str, Any]             # Session metadata<br>    thread_id: str                           # Memory persistence key</pre><p><strong>Key Design Decisions:</strong></p><ol><li><strong>Annotated Messages</strong>: The add_messages annotation automatically handles message list management</li><li><strong>Session Info</strong>: Tracks statistics like message count and tool usage. We are also going to log all this information</li><li><strong>Thread ID</strong>: Enables conversation persistence across sessions. By using thread ID, we will be able to continue our chats with chat memory.</li></ol><h3>Logging System</h3><p>We should implement a comprehensive logging since we would like to see the AI response, total message count, thread ID, whether tool was called per prompt or not, etc.</p><pre>class ColoredFormatter(logging.Formatter):<br>    &quot;&quot;&quot;Custom colored formatter for better log readability.&quot;&quot;&quot;<br>    <br>    COLORS = {<br>        &#39;DEBUG&#39;: &#39;\033[36m&#39;,    # Cyan<br>        &#39;INFO&#39;: &#39;\033[32m&#39;,     # Green  <br>        &#39;WARNING&#39;: &#39;\033[33m&#39;,  # Yellow<br>        &#39;ERROR&#39;: &#39;\033[31m&#39;,    # Red<br>        &#39;CRITICAL&#39;: &#39;\033[35m&#39;, # Magenta<br>    }<br>    RESET = &#39;\033[0m&#39;<br>    <br>    def format(self, record):<br>        log_color = self.COLORS.get(record.levelname, self.RESET)<br>        record.levelname = f&quot;{log_color}{record.levelname}{self.RESET}&quot;<br>        return super().format(record)<br>    <br>    def setup_logging():<br>        &quot;&quot;&quot;Setup enhanced logging with file and console handlers.&quot;&quot;&quot;<br>        logger = logging.getLogger(__name__)<br>        logger.setLevel(logging.INFO)<br>        <br>        # File handler for persistent logs<br>        file_handler = logging.FileHandler(&quot;chatbot.log&quot;)<br>        file_formatter = logging.Formatter(<br>            &quot;%(asctime)s | %(name)s | %(levelname)s | %(funcName)s:%(lineno)d | %(message)s&quot;<br>        )<br>        file_handler.setFormatter(file_formatter)<br>        <br>        # Console handler with colors<br>        console_handler = logging.StreamHandler()<br>        console_formatter = ColoredFormatter(<br>            &quot;%(asctime)s | %(levelname)s | %(funcName)s | %(message)s&quot;,<br>            datefmt=&quot;%H:%M:%S&quot;<br>        )<br>        console_handler.setFormatter(console_formatter)<br>        <br>        logger.addHandler(file_handler)<br>        logger.addHandler(console_handler)<br>        <br>        return logger</pre><p><strong>Logging Features:</strong></p><ul><li><strong>Dual Output</strong>: Both file and console logging (file logging inside <strong>chatbot.log</strong>)</li><li><strong>Color Coding</strong>: Different colors for different log levels for better visibility</li><li><strong>Detailed Format</strong>: Includes timestamp, function name, and line number</li><li><strong>Structured Tracking</strong>: Every operation is logged for debugging</li></ul><h3>LLM Initialization</h3><p>We initialize Google’s Gemini 2.5 Flash model. The reason of using this model is about the pricing and the responses are still satisfactory.</p><pre>def initialize_llm():<br>    &quot;&quot;&quot;Initialize the LLM with comprehensive error handling.&quot;&quot;&quot;<br>    logger.info(&quot;Initializing Google Gemini 2.5 Flash model...&quot;)<br>    <br>    api_key = os.getenv(&quot;GOOGLE_API_KEY&quot;)<br>    if not api_key:<br>        logger.error(&quot;GOOGLE_API_KEY not found in environment variables&quot;)<br>        raise ValueError(&quot;GOOGLE_API_KEY not found&quot;)<br>    <br>    try:<br>        llm = init_chat_model(<br>            &quot;gemini-2.5-flash&quot;, <br>            model_provider=&quot;google_genai&quot;, <br>            api_key=api_key,<br>            temperature=0.7  # Balanced creativity and consistency<br>        )<br>        logger.info(&quot;Successfully initialized Google Gemini 2.5 Flash model&quot;)<br>        return llm<br>    except Exception as e:<br>        logger.error(f&quot;Failed to initialize LLM: {str(e)}&quot;)<br>        raise</pre><p><strong>Key Features:</strong></p><ul><li><strong>Environment Validation</strong>: Checks for API key presence</li><li><strong>Error Handling</strong>: Graceful failure with detailed logging</li><li><strong>Configuration</strong>: Temperature set to 0.7 for balanced responses including both creativity and consistency</li></ul><h3>Tool Integration</h3><p>Our chatbot integrates Tavily search as an optional tool. Afterwards, we are going to leverage conditional routing. If the answer requires model to search web , it is going to fork into tool calling and chatbot will use Tavily search.</p><pre>def initialize_tools():<br>    &quot;&quot;&quot;Initialize tools with enhanced logging.&quot;&quot;&quot;<br>    logger.info(&quot;Initializing available tools...&quot;)<br>    tools = []<br>    <br>    # Add Tavily search tool if API key is available<br>    tavily_api_key = os.getenv(&quot;TAVILY_API_KEY&quot;)<br>    if tavily_api_key:<br>        try:<br>            search_tool = TavilySearch(<br>                max_results=3,<br>                description=&quot;Search the web for current information, news, and real-time data. Use this when you need up-to-date information that you don&#39;t have in your training data.&quot;,<br>            )<br>            tools.append(search_tool)<br>            logger.info(&quot;Successfully added Tavily search tool&quot;)<br>        except Exception as e:<br>            logger.error(f&quot;Failed to initialize Tavily search: {str(e)}&quot;)<br>    else:<br>        logger.warning(&quot;TAVILY_API_KEY not found - web search functionality disabled&quot;)<br>    <br>    logger.info(f&quot;Tool initialization complete. Available tools: {len(tools)}&quot;)<br>    return tools</pre><p><strong>Tool Features:</strong></p><ul><li><strong>Optional Integration</strong>: Functions with or without Tavily API key</li><li><strong>Smart Configuration</strong>: Limited to 3 results for concise responses</li><li><strong>Descriptive</strong>: Clear description helps LLM decide when to use</li><li><strong>Graceful Degradation</strong>: Works even if search is unavailable</li></ul><h3>Graph Architecture Deep Dive</h3><h3>Node Implementation</h3><h4>Statistics Update Node</h4><p>We are going to keep the statistics of the chat using this node. With every new message, the stats are updated and we also log those into the console as well as the log file.</p><pre>def update_session_stats(state: State):<br>    &quot;&quot;&quot;Update session statistics and user profile.&quot;&quot;&quot;<br>    logger.debug(&quot;Updating session statistics...&quot;)<br>    <br>    # Initialize or update session info<br>    session_info = state.get(&quot;session_info&quot;, {})<br>    if &quot;message_count&quot; not in session_info:<br>        session_info[&quot;start_time&quot;] = datetime.now().isoformat()<br>        session_info[&quot;message_count&quot;] = 0<br>        session_info[&quot;tool_usage_count&quot;] = 0<br>    <br>    session_info[&quot;message_count&quot;] += 1<br>    session_info[&quot;last_activity&quot;] = datetime.now().isoformat()<br>    <br>    # Count tool usage in recent messages<br>    messages = state.get(&quot;messages&quot;, [])<br>    if messages:<br>        last_message = messages[-1]<br>        if hasattr(last_message, &#39;tool_calls&#39;) and last_message.tool_calls:<br>            session_info[&quot;tool_usage_count&quot;] += len(last_message.tool_calls)<br>    <br>    return {&quot;session_info&quot;: session_info}</pre><h4>Main Chatbot Node</h4><p>We use this node as the main chatbot. This node is going to receive the Human prompt and provide the response accordingly.</p><pre>def chatbot_node(state: State):<br>    &quot;&quot;&quot;Main chatbot processing node with enhanced error handling.&quot;&quot;&quot;<br>    logger.info(&quot;Processing user message through chatbot node...&quot;)<br>    <br>    try:<br>        # Generate dynamic system prompt<br>        system_prompt = get_system_prompt(state)<br>        <br>        # Prepare messages for LLM<br>        messages = [SystemMessage(content=system_prompt)] + state[&quot;messages&quot;]<br>        <br>        # Get LLM response<br>        response = llm_with_tools.invoke(messages)<br>        <br>        # Log response details<br>        if hasattr(response, &#39;tool_calls&#39;) and response.tool_calls:<br>            tool_names = [tc.get(&quot;name&quot;, &quot;unknown&quot;) for tc in response.tool_calls]<br>            logger.info(f&quot;LLM requested tool usage: {&#39;, &#39;.join(tool_names)}&quot;)<br>        <br>        return {&quot;messages&quot;: [response]}<br>        <br>    except Exception as e:<br>        logger.error(f&quot;Error in chatbot_node: {str(e)}&quot;)<br>        error_response = AIMessage(<br>            content=&quot;I apologize, but I encountered an error processing your request.&quot;<br>        )<br>        return {&quot;messages&quot;: [error_response]}</pre><h3>Smart Routing Logic</h3><p>Using this node, the graph decides whether to use a tool (Tavily) or not. If the tool is required, it is going to look for an answer by searching web.</p><pre>def should_use_tools(state: State) -&gt; str:<br>    &quot;&quot;&quot;Determine if we should route to tools or end the conversation.&quot;&quot;&quot;<br>    logger.debug(&quot;Evaluating routing decision...&quot;)<br>    <br>    messages = state.get(&quot;messages&quot;, [])<br>    if not messages:<br>        return &quot;end&quot;<br>    <br>    last_message = messages[-1]<br>    <br>    # Check if LLM requested tools<br>    if hasattr(last_message, &#39;tool_calls&#39;) and last_message.tool_calls:<br>        logger.info(f&quot;Routing to tools - {len(last_message.tool_calls)} tool calls requested&quot;)<br>        return &quot;tools&quot;<br>    <br>    logger.debug(&quot;No tools requested, routing to end&quot;)<br>    return &quot;end&quot;</pre><h3>Graph Construction</h3><p>This is going to be the final step. By using this function, we create the chatbot graph with all of the above nodes included.</p><pre>def build_chatbot_graph():<br>    &quot;&quot;&quot;Build the complete chatbot graph with SQLite persistence.&quot;&quot;&quot;<br>    logger.info(&quot;Building LangGraph chatbot workflow...&quot;)<br>    <br>    # Create StateGraph<br>    graph_builder = StateGraph(State)<br>    <br>    # Add nodes<br>    graph_builder.add_node(&quot;update_stats&quot;, update_session_stats)<br>    graph_builder.add_node(&quot;chatbot&quot;, chatbot_node)<br>    <br>    # Add tools node if available<br>    if tools:<br>        tool_node = ToolNode(tools)<br>        graph_builder.add_node(&quot;tools&quot;, tool_node)<br>    <br>    # Define edges and routing<br>    graph_builder.add_edge(START, &quot;update_stats&quot;)<br>    graph_builder.add_edge(&quot;update_stats&quot;, &quot;chatbot&quot;)<br>    <br>    # Conditional routing from chatbot<br>    if tools:<br>        graph_builder.add_conditional_edges(<br>            &quot;chatbot&quot;,<br>            should_use_tools,<br>            {&quot;tools&quot;: &quot;tools&quot;, &quot;end&quot;: END}<br>        )<br>        graph_builder.add_edge(&quot;tools&quot;, &quot;chatbot&quot;)<br>    else:<br>        graph_builder.add_edge(&quot;chatbot&quot;, END)<br>    <br>    # Initialize SQLite checkpointer for memory persistence<br>    db_path = &quot;chatbot_checkpoint.db&quot;<br>    checkpointer = SqliteSaver(<br>        sqlite3.connect(db_path, check_same_thread=False)<br>    )<br>    <br>    # Compile graph<br>    compiled_graph = graph_builder.compile(checkpointer=checkpointer)<br>    logger.info(&quot;LangGraph workflow compiled successfully&quot;)<br>    <br>    return compiled_graph</pre><p><strong>Graph Flow:</strong></p><p>You may see a simple visualization on how the whole graph looks like.</p><pre>START → update_stats → chatbot → [conditional routing]<br>                                      ↓<br>                               tools → chatbot (loop back)<br>                                      ↓  <br>                                     END</pre><h3>Advanced Features</h3><h3>Dynamic System Prompt Generation</h3><p>A <strong>system prompt</strong> is a special instruction given to an AI model before the conversation starts. The purpose is to <strong>set the rules, goals, and behavior</strong> of the AI throughout the interaction. It will be really useful for the accuracy of the response.</p><pre>def get_system_prompt(state: State) -&gt; str:<br>    &quot;&quot;&quot;Generate context-aware system prompt.&quot;&quot;&quot;<br>    session_info = state.get(&quot;session_info&quot;, {})<br>    <br>    system_prompt = &quot;&quot;&quot;You are a helpful, knowledgeable, and friendly AI assistant.<br>      CORE PRINCIPLES:<br>      - Be conversational and natural in your responses<br>      - Provide accurate, helpful information<br>      - Ask clarifying questions when needed<br>      - Maintain context from our conversation history<br>      TOOL USAGE:<br>      - You have access to web search capabilities<br>      - Use search tools ONLY when you need current information or real-time data<br>      - If you can answer from your knowledge, do so directly<br>      - When using tools, briefly explain what you&#39;re searching for<br>      CONVERSATION GUIDELINES:<br>      - Reference previous parts of our conversation when relevant<br>      - Be concise but thorough<br>      - Use a friendly, approachable tone<br>      - Adapt your explanations to the user&#39;s apparent level of understanding<br>      &quot;&quot;&quot;<br>    <br>    if session_info.get(&quot;message_count&quot;, 0) &gt; 1:<br>        system_prompt += f&quot;\n- This is message #{session_info[&#39;message_count&#39;]} in our conversation&quot;<br>    <br>    return system_prompt</pre><h3>Memory Persistence with SQLite</h3><p>Our chatbot uses SQLite checkpoints to maintain conversation memory. With every prompt without providing the thread ID, a new ID will be created and saved into SQLite. Chatbot will display the specific thread ID in the console. We can set it as an argument for chatbot to be able to remember earlier prompts.</p><pre># Each conversation has a unique thread_id<br>thread_id = args.thread or f&quot;chat_{uuid.uuid4().hex[:8]}&quot;<br># Configuration for memory persistence<br>config = {&quot;configurable&quot;: {&quot;thread_id&quot;: thread_id}}<br># Graph execution with memory<br>result = graph.invoke(inputs, config)</pre><p><strong>Memory Features:</strong></p><ul><li><strong>Thread-based</strong>: Each conversation thread has isolated memory</li><li><strong>Persistent</strong>: Conversations survive application restarts</li><li><strong>Automatic</strong>: No manual checkpoint management required</li><li><strong>Efficient</strong>: SQLite provides fast, reliable storage</li></ul><h3>Next Steps</h3><p>Since we went through each step of the main script, we can move forward with next steps to run the scripts.</p><h4>Running Demo</h4><pre># Run feature demonstration<br>make demo</pre><p>This executes demo_chatbot.py which showcases all key features without requiring user input. It will provide a simple “Hello” as the user input and check if the chatbot script runs properly and provides response.</p><h4>Cleaning Generated Files</h4><pre># Clean all generated files (logs, database, cache)<br>make clean</pre><p>This removes:</p><ul><li>chatbot.log (log files)</li><li>chatbot_checkpoint.db (SQLite database)</li><li>__pycache__ directories</li><li>.pytest_cache directories</li><li>All .pyc and .pyo files</li></ul><h4>Running the Chatbot</h4><p>Since we completed all previous steps, we can run the main script. We have two way of doing this. We can either leverage Makefile again or running commands ourselves.</p><p><strong>Makefile:</strong></p><pre># Single message mode<br># It will provide a single &quot;Hello&quot; message to the model and get an answer<br>make run<br><br># Interactive conversation mode<br># By running this, we can start an interactive chat with a simple &quot;Hello&quot; input<br>make run-interactive</pre><p><strong>Terminal Commands:</strong></p><pre># Start conversation<br>uv run main.py --message &quot;Hello&quot;<br><br># You will get the first response<br># If you want to keep the chat memory, you can provide the next message with thread<br># You may see thread ID in the logs in console or in console.log (see image below)<br>uv run main.py --thread chat_732b9e45 --message &quot;Next Message&quot;<br><br><br># Or if you want to start an interactive chat, run this command<br># Then, you can keep sending prompts<br>uv run main.py --message &#39;Your message&#39; --interactive<br><br></pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3u9lXwYWUjuatkQ-8EYXwA.png" /><figcaption>First message log</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f-ETMRqg5cbn0Lr5gvgnTA.png" /><figcaption>Interactive chat log</figcaption></figure><h3>Usage Examples</h3><p>In this last section, we will go through some sample messages.</p><h3>Single Message Mode</h3><pre># Using Makefile command<br>make run<br># Or direct Python execution<br>uv run main.py --message &quot;What&#39;s the latest news about AI?&quot;</pre><h3>Interactive Mode</h3><pre># Using Makefile command<br>make run-interactive<br># Or direct Python execution<br>uv run main.py --message &quot;Hello!&quot; --interactive</pre><h3>Continuing Previous Conversations</h3><pre># Resume conversation with specific thread ID<br>uv run main.py --thread abc123 --message &quot;What did we discuss earlier?&quot; --interactive</pre><h3>Debug Mode</h3><pre># Enable detailed logging<br>uv run main.py --message &quot;Hello!&quot; --debug --interactive</pre><h3>Conclusion</h3><p>This LangGraph Chatbot demonstrates modern AI application development with:</p><ul><li><strong>Professional Setup</strong>: UV package manager and Makefile automation</li><li><strong>Robust Architecture</strong>: Graph-based workflow with conditional routing</li><li><strong>Memory Persistence</strong>: SQLite checkpoints for conversation continuity</li><li><strong>Tool Integration</strong>: Optional web search capabilities with Tavily</li><li><strong>Production-Ready</strong>: Comprehensive logging, error handling, and testing</li></ul><p>The modular design makes it easy to extend with additional tools, modify the conversation flow, or integrate with different LLM providers. The comprehensive Makefile ensures consistent development workflows across different environments.</p><p><strong>Key Takeaways:</strong></p><ol><li><strong>Makefile Automation</strong>: Simplifies complex development workflows</li><li><strong>LangGraph Power</strong>: Enables sophisticated conversation flows</li><li><strong>Memory Management</strong>: Persistent state enhances user experience with SQLite</li><li><strong>Tool Integration</strong>: Extends LLM capabilities with external services (Tavily)</li><li><strong>Production Considerations</strong>: Logging, testing, and error handling are crucial</li></ol><p>This implementation serves as a solid foundation for building more complex conversational AI applications. We will continue with other applications.</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b9aeada69043" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Real-Time Stock Sentiment Pipeline with Apache Kafka, Claude AI, and TimescaleDB]]></title>
            <link>https://medium.com/@dogukannulu/real-time-stock-sentiment-pipeline-with-apache-kafka-claude-ai-and-timescaledb-10df5d670b8e?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/10df5d670b8e</guid>
            <category><![CDATA[finance]]></category>
            <category><![CDATA[grafana]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[kafka]]></category>
            <category><![CDATA[claude]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Sun, 05 Jul 2026 14:29:09 GMT</pubDate>
            <atom:updated>2026-07-05T14:29:09.617Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*df3jOuIRCGv5lXytvhVjDw.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/stock-sentiment-platform">GitHub - dogukannulu/stock-sentiment-platform</a></p><h3>The Problem</h3><p>Financial sentiment data is everywhere. Earnings summaries, analyst notes, news wires — all of it moves markets. The problem is that nobody is processing it in real time for individual tickers, correlating it with live price data, and making it queryable through plain English.</p><p>Commercial solutions exist, but they’re priced for hedge funds. Open-source alternatives are either stale demos or toy scripts that fall apart under real load.</p><p>I wanted to build something production-grade: proper schemas, a real message broker, time-series storage, version-controlled SQL transformations, a live dashboard, an AI analyst agent, and an evaluation suite that proves the sentiment scoring actually works.</p><p>Here’s everything I built, how I built it, and the lessons I learned along the way.</p><h3>Architecture Overview</h3><pre>Yahoo Finance (yfinance)      Alpha Vantage News API<br>        |                              |<br>  price_producer.py           news_producer.py<br>        |                              |<br>  Kafka: price-ticks          Kafka: news-articles<br>  (Avro schema)               (Avro schema)<br>                    |<br>            Confluent Schema Registry<br>                    |<br>            Sentiment Job (Python threads)<br>  (Claude Haiku scores each article → TimescaleDB)<br>                    |<br>            TimescaleDB<br>(price_ticks · news_articles · sentiment_events)<br>                    |<br>              dbt models<br>(staging views → sentiment_signals mart → analytics schema)<br>                    |<br>        ┌───────────┴────────────┐<br>   Grafana Dashboard      AI Market Analyst Agent<br>   (4 live panels)        (LangGraph + FastAPI /brief)</pre><h3>The Stack</h3><ul><li>Apache Kafka + Zookeeper</li><li>Confluent Schema Registry (Avro)</li><li>Threaded Kafka Consumers (Python)</li><li>Anthropic Claude API (claude-haiku-4–5)</li><li>TimescaleDB (Postgres 15)</li><li>dbt-postgres</li><li>Grafana</li><li>LangGraph + FastAPI</li><li>DeepEval GEval</li><li>Docker</li></ul><h3>Step 1: Setting Up the Infrastructure</h3><p>Everything except the Python processes runs in Docker Compose — Kafka, Zookeeper, Kafka UI, Confluent Schema Registry, TimescaleDB, and Grafana. The full stack comes up with one command:</p><pre>docker compose up -d<br>docker ps<br># 6 containers: zookeeper, kafka, kafka-ui, schema-registry, timescaledb, grafana</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cgSf4JJRZt9H9SNI4XrpKQ.png" /><figcaption>All Docker containers</figcaption></figure><p>The key design decision here: Kafka acts as the durable buffer between data ingestion and processing. If the sentiment job goes down while Claude is being slow or rate-limited, messages queue up in Kafka and are processed when the job restarts. The producers never block.</p><p><strong>Why Avro + Schema Registry?</strong> Because raw JSON is a footgun at scale. Schema Registry enforces data contracts at the message level — if a producer sends a message with a missing required field, the registry rejects it before the message even enters the topic. Here’s the price tick schema:</p><pre>{<br>  &quot;type&quot;: &quot;record&quot;,<br>  &quot;name&quot;: &quot;PriceTick&quot;,<br>  &quot;fields&quot;: [<br>    {&quot;name&quot;: &quot;event_time&quot;, &quot;type&quot;: &quot;string&quot;},<br>    {&quot;name&quot;: &quot;ticker&quot;,     &quot;type&quot;: &quot;string&quot;},<br>    {&quot;name&quot;: &quot;price&quot;,      &quot;type&quot;: &quot;double&quot;},<br>    {&quot;name&quot;: &quot;volume&quot;,     &quot;type&quot;: [&quot;null&quot;, &quot;long&quot;], &quot;default&quot;: null},<br>    {&quot;name&quot;: &quot;source&quot;,     &quot;type&quot;: &quot;string&quot;, &quot;default&quot;: &quot;yahoo_finance&quot;}<br>  ]<br>}</pre><h3>Step 2: TimescaleDB Schema</h3><p>TimescaleDB is Postgres with automatic time-based partitioning. That means a query like “give me NVDA sentiment for the last 24 hours” hits a partition scan instead of a full table scan — orders of magnitude faster at scale.</p><p>Three hypertables power this whole system:</p><pre>-- Raw price ticks from Yahoo Finance<br>CREATE TABLE IF NOT EXISTS price_ticks (<br>    event_time  TIMESTAMPTZ NOT NULL,<br>    ticker      TEXT        NOT NULL,<br>    price       NUMERIC     NOT NULL,<br>    volume      BIGINT,<br>    source      TEXT        DEFAULT &#39;yahoo_finance&#39;<br>);<br>SELECT create_hypertable(&#39;price_ticks&#39;, &#39;event_time&#39;, if_not_exists =&gt; TRUE);<br><br>-- Raw news articles from Alpha Vantage<br>CREATE TABLE IF NOT EXISTS news_articles (<br>    event_time          TIMESTAMPTZ NOT NULL,<br>    ticker              TEXT        NOT NULL,<br>    article_id          TEXT        UNIQUE,<br>    title               TEXT,<br>    body                TEXT,<br>    source              TEXT,<br>    url                 TEXT,<br>    av_sentiment_score  NUMERIC,<br>    av_sentiment_label  TEXT<br>);<br>SELECT create_hypertable(&#39;news_articles&#39;, &#39;event_time&#39;, if_not_exists =&gt; TRUE);<br>-- Enriched sentiment events (output of Claude scoring)<br>CREATE TABLE IF NOT EXISTS sentiment_events (<br>    event_time      TIMESTAMPTZ NOT NULL,<br>    ticker          TEXT        NOT NULL,<br>    source          TEXT        NOT NULL,<br>    sentiment_score NUMERIC,<br>    sentiment_label TEXT,<br>    raw_text        TEXT,<br>    price_at_event  NUMERIC,<br>    metadata        JSONB<br>);<br>SELECT create_hypertable(&#39;sentiment_events&#39;, &#39;event_time&#39;, if_not_exists =&gt; TRUE);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/686/1*VhYF4ioeQqGLYrx9EbD53Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J_2S9OP9Zdk0-nCyZ1yP0g.png" /></figure><h3>Step 3: The Kafka Producers</h3><p>Two independent producers run in separate terminals (or as separate processes):</p><p><strong>price_producer.py</strong> polls Yahoo Finance every 60 seconds for 5 tickers: AAPL, GOOGL, MSFT, TSLA, NVDA.</p><pre>def fetch_and_produce(producer):<br>    for ticker in TICKERS:<br>        data = yf.Ticker(ticker)<br>        info = data.fast_info<br>        price = info.last_price<br>    if price is None:<br>            logger.warning(f&quot;No price data for {ticker}, skipping.&quot;)<br>            continue<br>        message = {<br>            &quot;event_time&quot;: datetime.now(timezone.utc).isoformat(),<br>            &quot;ticker&quot;: ticker,<br>            &quot;price&quot;: round(float(price), 4),<br>            &quot;volume&quot;: int(info.three_month_average_volume) if info.three_month_average_volume else None,<br>            &quot;source&quot;: &quot;yahoo_finance&quot;,<br>        }<br>        producer.send(KAFKA_TOPIC, value=message)<br>        logger.info(f&quot;Sent price tick: {ticker} @ {price}&quot;)</pre><p><strong>news_producer.py</strong> polls the Alpha Vantage News Sentiment API every 60 minutes (free tier: 25 calls/day), deduplicates by article_id, and sends each article to the news-articles topic:</p><pre>for ticker in TICKERS:<br>    url = (<br>        f&quot;https://www.alphavantage.co/query&quot;<br>        f&quot;?function=NEWS_SENTIMENT&amp;tickers={ticker}&quot;<br>        f&quot;&amp;limit=10&amp;apikey={AV_API_KEY}&quot;<br>    )<br>    resp = requests.get(url, timeout=10)<br>    data = resp.json()<br>    for item in data.get(&quot;feed&quot;, []):<br>        article_id = item.get(&quot;url&quot;, &quot;&quot;)[-80:]<br>        if article_id in seen_ids:<br>            continue<br>        seen_ids.add(article_id)<br>        # ... send to Kafka</pre><p>Before running the producers, register the Avro schemas:</p><pre>python producers/kafka_admin.py</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j4QVDAvG0IBIYPO7NZBkCg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i8XuVJM8cG17yFsnfd1TiQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mVAb5eXADKCr83vxczsOBg.png" /></figure><h3>Step 4: The Sentiment Job — Where Claude Does the Work</h3><p>This is the core AI layer. A Python process runs two threads: one consuming price ticks (simple DB write), one consuming news articles (Claude API call + DB write).</p><p><strong>The prompt engineering:</strong></p><pre>SENTIMENT_PROMPT = &quot;&quot;&quot;Analyze the sentiment of this financial news article \<br>toward the stock ticker mentioned.<br>Respond ONLY with a JSON object, no other text:<br>{{&quot;score&quot;: &lt;float between -1.0 and 1.0&gt;, &quot;label&quot;: &quot;&lt;positive|negative|neutral&gt;&quot;, \<br>&quot;reasoning&quot;: &quot;&lt;one sentence&gt;&quot;}}Article: {text}&quot;&quot;&quot;<br></pre><p>The prompt does three things:</p><ol><li>Grounds Claude on <em>financial</em> sentiment specifically, not general sentiment</li><li>Forces structured JSON output — no parsing ambiguity. Here, we would have used Pydantic, but simple prompt works as expected for this project.</li><li>Asks for a reasoning field, which becomes invaluable for debugging and the GEval evaluation later.</li></ol><p><strong>The Claude call inside </strong><strong>NewsEnricher.map():</strong></p><pre>message = self.client.messages.create(<br>    model=&quot;claude-haiku-4-5-20251001&quot;,<br>    max_tokens=200,<br>    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: SENTIMENT_PROMPT.format(text=text)}],<br>)<br>raw_response = message.content[0].text.strip()<br># Strip markdown code fences if Claude wraps the JSON in ```json ... ```<br>if raw_response.startswith(&quot;```&quot;):<br>    raw_response = raw_response.split(&quot;```&quot;)[1]<br>    if raw_response.startswith(&quot;json&quot;):<br>        raw_response = raw_response[4:]<br>    raw_response = raw_response.strip()<br>sentiment = json.loads(raw_response)</pre><p>That code-fence stripping tripped me up early on. Claude occasionally wraps JSON responses in json blocks despite explicit instructions not to — always strip defensively.</p><p><strong>Why threaded consumers instead of PyFlink?</strong></p><p>My original plan used PyFlink. After spending an afternoon fighting apache-beam dependency failures on Python 3.12, I scrapped it. For a single-node local pipeline, threaded consumers give identical behavior — the business logic (Claude API call, DB write) is identical either way. No JVM, no classpath hell, zero configuration overhead.</p><pre>def main():<br>    news_thread = threading.Thread(<br>        target=_run_consumer,<br>        args=(&quot;news-articles&quot;, &quot;sentiment-job-news&quot;, NewsEnricher()),<br>        daemon=True,<br>    )<br>    price_thread = threading.Thread(<br>        target=_run_consumer,<br>        args=(&quot;price-ticks&quot;, &quot;sentiment-job-price&quot;, PriceWriter()),<br>        daemon=True,<br>    )<br>    news_thread.start()<br>    price_thread.start()<br>    try:<br>        news_thread.join()<br>        price_thread.join()<br>    except KeyboardInterrupt:<br>        logger.info(&quot;Shutting down sentiment job.&quot;)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nz-f0ai3tvLfYAJFbX5jBQ.png" /></figure><h3>Step 5: TimescaleDB + dbt — From Raw Events to Business Signals</h3><p>Raw sentiment events are noisy. A single article might be an outlier. The sentiment_signals mart aggregates by ticker per hour, computes the average sentiment score, counts positive/negative/neutral articles, and assigns a signal classification.</p><p>The dbt project has three layers:</p><pre>staging/<br>  stg_price_ticks.sql          ← clean view over price_ticks (last 7 days)<br>  stg_sentiment_events.sql     ← clean view over sentiment_events (last 7 days)<br>marts/<br>  sentiment_signals.sql        ← hourly aggregates, bullish/bearish/neutral signal<br>analytics/<br>  sentiment_by_symbol.sql      ← per-symbol summary with momentum<br>  trending_tickers.sql         ← tickers with most sentiment movement<br>  price_sentiment_correlation.sql  ← correlation coefficient by ticker</pre><p>The mart SQL is where the signal gets defined:</p><pre>case<br>    when s.avg_sentiment &gt;  0.3 then &#39;bullish&#39;<br>    when s.avg_sentiment &lt; -0.3 then &#39;bearish&#39;<br>    else &#39;neutral&#39;<br>end as signal</pre><p>Running all 6 models takes about 0.6 seconds:</p><pre>cd dbt &amp;&amp; dbt run<br># 06:12:43  6 of 6 OK created sql table model analytics.price_sentiment_correlation<br># 06:12:43  Finished running 6 models in 0 hours 0 minutes and 0.61 seconds</pre><h3>Step 6: The Grafana Dashboard</h3><p>This is the payoff moment. Four panels, all querying TimescaleDB directly, refreshing every 30 seconds.</p><p><strong>Panel 1 — Sentiment Score Over Time (Time Series)</strong></p><pre>SELECT<br>  time_bucket(&#39;15 minutes&#39;, event_time) AS time,<br>  ticker,<br>  avg(sentiment_score) AS avg_sentiment<br>FROM sentiment_events<br>WHERE $__timeFilter(event_time)<br>GROUP BY 1, 2<br>ORDER BY 1</pre><p><strong>Panel 2 — Current Signal per Ticker (Stat panel)</strong></p><pre>SELECT ticker, signal, avg_sentiment<br>FROM sentiment_signals<br>WHERE event_hour = date_trunc(&#39;hour&#39;, now())<br>ORDER BY ticker</pre><p><strong>Panel 3 — Price vs Sentiment Overlay (dual Y-axis)</strong></p><pre>SELECT<br>  time_bucket(&#39;1 minute&#39;, p.event_time) AS time,<br>  p.ticker,<br>  avg(p.price) AS price,<br>  avg(s.sentiment_score) AS sentiment<br>FROM price_ticks p<br>LEFT JOIN sentiment_events s<br>  ON  time_bucket(&#39;15 minutes&#39;, p.event_time)<br>    = time_bucket(&#39;15 minutes&#39;, s.event_time)<br>  AND p.ticker = s.ticker<br>WHERE $__timeFilter(p.event_time)<br>GROUP BY 1, 2<br>ORDER BY 1</pre><p><strong>Panel 4 — News Article Volume (Bar chart)</strong></p><pre>SELECT<br>  time_bucket(&#39;1 hour&#39;, event_time) AS time,<br>  ticker,<br>  count(*) AS article_count<br>FROM news_articles<br>WHERE $__timeFilter(event_time)<br>GROUP BY 1, 2<br>ORDER BY 1</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ySXohwSigSD633uTZwrhjw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TOJBdmeTWv40zOeQQA2ddQ.png" /></figure><h3>Step 7: The AI Market Analyst Agent</h3><p>Beyond the dashboard, I built a natural-language query interface using LangGraph and FastAPI. Ask it anything about the market data, it figures out what SQL to run, queries the analytics tables, and synthesizes a market brief.</p><p>The agent has a single SQL tool:</p><pre>@tool<br>def query_sentiment_data(sql: str) -&gt; str:<br>    &quot;&quot;&quot;Execute a read-only SQL query against the analytics schema.&quot;&quot;&quot;<br>    conn = psycopg2.connect(**DB_CONFIG)<br>    try:<br>        with conn.cursor() as cur:<br>            cur.execute(sql)<br>            columns = [d[0] for d in cur.description]<br>            rows = cur.fetchmany(50)<br>            return json.dumps({&quot;columns&quot;: columns, &quot;rows&quot;: rows}, default=str)<br>    finally:<br>        conn.close()</pre><p>The agent is built with create_react_agent from LangGraph:</p><pre>def build_agent():<br>    llm = ChatAnthropic(model=&quot;claude-haiku-4-5-20251001&quot;, temperature=0)<br>    return create_react_agent(llm, [query_sentiment_data], prompt=SYSTEM_PROMPT)</pre><p>Real query and output:</p><pre>curl -s -X POST http://localhost:8000/brief \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;question&quot;: &quot;Which ticker has the most bearish sentiment right now?&quot;}&#39; \<br>  | jq .brief</pre><pre>**Symbol:** TSLA<br>**Sentiment:** Strongly negative at -0.78, deteriorating over last 3 hours<br>**Price:** $172.31, down 1.2% in the last hour<br>**Signal:** Bearish sentiment confirmed by price action<br>**Confidence:** Medium (12 articles in window)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TMqFBKaD9Douz3RRdTKRIQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w4kKsHcSS-BKH_2znLgmSw.png" /></figure><h3>Step 8: Evaluating the AI — GEval with Claude as Judge</h3><p>The hardest question with any LLM pipeline: “Is the AI actually getting this right?”</p><p>I used DeepEval’s GEval metric framework, which lets you define evaluation criteria in plain English and score outputs against them. The twist: I used Claude Haiku as both the scorer <em>and</em> the judge, creating a closed evaluation loop with zero external dependencies.</p><p>Two metrics on a 50-article golden dataset (hand-labeled):</p><p><strong>Label Faithfulness</strong> — Does the label (positive/negative/neutral) match the reasoning?</p><pre>faithfulness_metric = GEval(<br>    name=&quot;Label Faithfulness&quot;,<br>    criteria=&quot;&quot;&quot;The sentiment label must be consistent with the reasoning.<br>    A &#39;positive&#39; label must have reasoning explaining why the article is positive.<br>    A &#39;negative&#39; label must have reasoning explaining bearish content.<br>    A &#39;neutral&#39; label should indicate balanced or factual reporting.&quot;&quot;&quot;,<br>    evaluation_params=[LLMTestCaseParams.ACTUAL_OUTPUT],<br>    model=_judge,<br>)</pre><p><strong>Score Direction Accuracy</strong> — Does the sign of the score (+/−) match the human label?</p><pre>score_calibration_metric = GEval(<br>    name=&quot;Score Direction Accuracy&quot;,<br>    criteria=&quot;&quot;&quot;The numeric score must match the expected sentiment direction.<br>    Positive scores (&gt;0) for positive sentiment.<br>    Negative scores (&lt;0) for negative sentiment.<br>    Near-zero scores (-0.3 to 0.3) for neutral sentiment.&quot;&quot;&quot;,<br>    evaluation_params=[LLMTestCaseParams.ACTUAL_OUTPUT, LLMTestCaseParams.EXPECTED_OUTPUT],<br>    model=_judge,<br>)</pre><p><strong>Results on the golden dataset:</strong></p><ul><li>faithfulness (10 examples) -&gt; <strong>100%</strong></li><li>accuracy (50 examples) -&gt; <strong>96%</strong> (48/50 correct sign)</li></ul><h3>Lessons Learned</h3><p><strong>PyFlink on Python 3.12 is a non-starter.</strong> The apache-beam dependency fails at install time with ModuleNotFoundError: No module named &#39;pkg_resources&#39;. I replaced the entire Flink runtime with threaded Kafka consumers in about 30 minutes. Identical behavior, zero JVM dependency. Don&#39;t fight your environment.</p><p><strong>Claude occasionally wraps JSON in code fences.</strong> Even with explicit instructions to respond “ONLY with a JSON object, no other text,” Claude will sometimes wrap the response in ```json blocks. Always strip defensively — one line of handling saves hours of debugging in production.</p><p><strong>DeepEval’s GEval requires a judge model, and it defaults to OpenAI.</strong> If you don’t have an OpenAI key, set model=AnthropicModel(...) explicitly. Using the same model as scorer and judge is methodologically fine for a closed evaluation — you&#39;re testing consistency, not ground truth. But, if we were evaluating the accuracy, using different models (even different LLM providers) would definitely be suggested.</p><p><strong>Kafka consumer group IDs matter more than you think.</strong> Two processes using the same group_id will split partition ownership, causing each to see only half the messages. Make group IDs explicit and unique per logical consumer. Similarly, zombie consumer processes from previous runs can hold partition leases for 30 seconds after they die — always pkill -f sentiment_job.py before a clean restart.</p><p><strong>TimescaleDB’s </strong><strong>time_bucket() is what makes Grafana queries elegant.</strong> Instead of GROUP BY DATE_TRUNC(&#39;15 minutes&#39;, event_time), you write GROUP BY time_bucket(&#39;15 minutes&#39;, event_time). The function is index-aware and communicates intent better in code reviews.</p><p>If this saved you time or sparked an idea, consider sponsoring me on GitHub — and if you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=10df5d670b8e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Cloud Engineering Project — Part 3— AWS Glue, Lambda, Kinesis, Redshift, Athena, QuickSight…]]></title>
            <link>https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-3-aws-glue-lambda-kinesis-redshift-athena-quicksight-3c3c00b99de0?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/3c3c00b99de0</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[redshift]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[data]]></category>
            <category><![CDATA[quicksight]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Sun, 28 Jun 2026 15:46:22 GMT</pubDate>
            <atom:updated>2026-07-05T14:34:56.231Z</atom:updated>
            <content:encoded><![CDATA[<h3>AWS Cloud Engineering Project — Part 3— AWS Glue, Lambda, Kinesis, Redshift, Athena, QuickSight, S3, EC2, Spark</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1001/1*xoJx8bM-v3_vTKKksew7CA.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline">GitHub - dogukannulu/aws_end_to_end_streaming_pipeline: An AWS Data Engineering End-to-End Project (Glue, Lambda, Kinesis, Redshift, QuickSight, Athena, EC2, S3)</a></p><p>You may see the part 2 (Data processing) below:</p><p><a href="https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-2-aws-glue-lambda-kinesis-redshift-athena-quicksight-c72b26876592">https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-2-aws-glue-lambda-kinesis-redshift-athena-quicksight-c72b26876592</a></p><h3>Overview</h3><p>In this part of the whole pipeline, we are moving on to the <strong>Data Analysis</strong> part. We are going to check if the data has correctly arrived in the S3 bucket and the corresponding Glue table has been created by the Glue ETL job. If so, we will explore the data using AWS Athena first. For the Athena, we are going to use the data in the Glue table. We are going to connect AWS Athena to DBeaver and run several advanced SQL queries against the data.</p><p>Then, we will create a Redshift cluster with suitable parameters. We are going to use the data in the final S3 bucket for our Redshift cluster. Since our resulting parquet files will be uploaded as small batches, it is not possible to create the resulting tables according to their individual locations. We will, instead, create an external schema, database, and table in the end. We will also use the S3 directory to do this, not single locations.</p><p>We are going to use QuickSight to create our final dashboard. We will connect both AWS Athena and Redshift for the underlying data of the dashboard.</p><h3>Monitor the Data</h3><p>The first thing we have to do is to check if the data has arrived correctly. After our Glue ETL job is successfully completed, we should have our resulting parquet files under <strong>s3://aws-glue-clean-books-parquet-dogukan-ulu/clean_books_parquet/</strong>. We can see that the files have been uploaded correctly below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uzdksk8rN7Tk-Rv3E3MQlQ.png" /></figure><p>Every time we run this whole pipeline, more parquet files will be added to this bucket and directory. Since the Glue table reads the data from this exact S3 directory, the underlying data in the Glue table will also be appended in each run. The parquet files should be cleaned and should look like this <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/glue_etl_job/clean_books_data.parquet">clean_books_data.parquet</a> file.</p><p>Now we should also check if the Glue table exists under the database <strong>glue-etl-books-parquet-dogukan-ulu</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/450/1*SWANIlY7MxkNkWSteWwffw.png" /></figure><p>We can see below that there are two Glue tables:</p><ul><li><strong>dirty_books_parquet:</strong> This is the table created by the Glue crawler in the previous part. It includes the dirty books data.</li><li><strong>clean_books_parquet:</strong> This is the resulting table of the Glue ETL Job. It includes the resulting cleaned parquet data.</li></ul><p>We can also see the locations of the Glue tables (S3 locations).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/942/1*kcvaTmlgSkOf8HZ3v8XApA.png" /></figure><p>Since we can see that our data has arrived correctly, we can move on to the AWS Athena stage.</p><h3>AWS Athena</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/tree/main/athena_queries">aws_end_to_end_streaming_pipeline/athena_queries at main · dogukannulu/aws_end_to_end_streaming_pipeline</a></p><p>Once we open the Athena home page on AWS, we can directly reach to the Glue tables.</p><p>We can connect to DBeaver using the Athena as the source data. It will help us run our queries however we want. You may take a look at the below article to have an insight into how to connect DBeaver to Athena.</p><p><a href="https://medium.com/@dogukannulu/aws-athena-and-dbeaver-connection-6a4118985c6a">https://medium.com/@dogukannulu/aws-athena-and-dbeaver-connection-6a4118985c6a</a></p><p>We can also use the Athena UI directly, but using DBeaver is another solution. Once connected, we will be able to see the Glue database and tables. We can run the following queries to better explore the data.</p><p><strong>Find the availability ratio:</strong></p><pre>WITH numbers AS (<br>    SELECT<br>        COUNT(<br>            CASE<br>                WHEN availability = 1 THEN 1<br>            END<br>        ) AS number_of_available_books,<br>        COUNT(*) as total_number_of_books<br>    FROM `glue-etl-books-parquet-dogukan-ulu.clean_books_parquet`<br>)<br><br>SELECT<br>    number_of_available_books/total_number_of_books as availability_ratio<br>FROM numbers</pre><p><strong>Categorize the data by price:</strong></p><pre>WITH all_price_categories AS (<br>    SELECT<br>        title,<br>        CASE<br>            WHEN price &lt;= 15 THEN &#39;Cheap&#39;<br>            WHEN price &lt;= 25 AND price &lt; 40 THEN &#39;Middle&#39;<br>            ELSE &#39;Expensive&#39;<br>        END as price_category<br>    FROM `glue-etl-books-parquet-dogukan-ulu.clean_books_parquet`<br>)<br><br>SELECT<br>    title,<br>    price_category = &#39;Cheap&#39; as is_cheap,<br>    price_category = &#39;Middle&#39; as is_middle,<br>    price_category = &#39;Expensive&#39; as is_expensive<br>FROM all_price_categories</pre><p><strong>Partition by number of reviews:</strong></p><pre>WITH max_price AS (<br>    SELECT<br>        title,<br>        price,<br>        num_reviews,<br>        ROW_NUMBER() OVER(PARTITION BY num_reviews ORDER BY price DESC) as price_row_number<br>    FROM `glue-etl-books-parquet-dogukan-ulu.clean_books_parquet`<br>)<br><br><br>SELECT<br>    title,<br>    price,<br>    num_reviews<br>FROM max_price<br>WHERE price_row_number = 1</pre><p>We can see more SQL queries in the above link to explore the data.</p><h3>AWS Redshift</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/tree/main/redshift_queries">aws_end_to_end_streaming_pipeline/redshift_queries at main · dogukannulu/aws_end_to_end_streaming_pipeline</a></p><p>First of all, we have to create an IAM role which will include the following policies:</p><ul><li>AmazonS3FullAccess</li><li>AmazonRedshiftAllCommandsFullAccess</li><li>AWSGlueServiceRole</li></ul><p>After creating the IAM role, we should also create a subnet group since it will require us to use a subnet group while creating the cluster. Under Configuration -&gt; Subnet groups, we can create a new subnet group. If we use the cluster for comparably simpler data loads, we can choose the smallest Node type and choose the number as 1 so that it won’t cost too much.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iyjHQHqumGEjUz5tcxWpbQ.png" /></figure><p>Then, we are going to define the username and password. This part is important since we will be using these while connecting to DBeaver.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZSPCh5Sy9yijR1QCpUroCQ.png" /></figure><p>After creating the username and password, we should uncheck the default settings and define the Network and security section ourselves.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/732/1*FQ_CZOr7mLzfFILzsJCI4Q.png" /></figure><p>We should choose the subnet group we already created and the VPC it is located. We can also choose a suitable security group. After creating the cluster, we will add Redshift as an inbound rule to our security group.</p><p>We should definitely check the <strong>Turn on Publicly accessible</strong> button so that we will be able to access our cluster from DBeaver.</p><p>We can leave all other options as default and create our cluster. It will take around 10 minutes. After creating, we can also run our queries using the Query editor on the Redshift dashboard, but we will use DBeaver. If you want to know how we can create a DBeaver and Redshift connection, you may take a look at the below article.</p><p><a href="https://medium.com/@dogukannulu/aws-redshift-and-dbeaver-connection-39d3f4d2016f">https://medium.com/@dogukannulu/aws-redshift-and-dbeaver-connection-39d3f4d2016f</a></p><p>Once connected, we have to create an external schema, database, and table. We are going to use this table to query the data.</p><p>We are going to first create the external schema and database.</p><pre>create external schema redshift_parquet<br>from data catalog<br>database &#39;glue-etl-books-parquet-dogukan-ulu-external&#39;<br>iam_role &#39;&lt;iam_role_arn&gt;&#39;<br>create external database if not exists;</pre><p>We can check if the database <strong>glue-etl-books-parquet-dogukan-ulu-external</strong> is created under the Glue databases. If so, we can run the following query to create the external table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/444/1*5viWzoWCt9ALv3oT8pyGvg.png" /></figure><pre>CREATE EXTERNAL TABLE redshift_parquet.clean_books_parquet(<br>    title VARCHAR(100),<br>    price FLOAT,<br>    num_reviews INTEGER,<br>    availability INTEGER<br>)<br>stored as parquet<br>location &#39;s3://aws-glue-clean-books-parquet-dogukan-ulu/clean_books_parquet/&#39;;</pre><p>You should change the location part according to your S3 location. After the run is successful, we can select the table and see the results.</p><pre>SELECT * FROM dev.redshift_parquet.clean_books_parquet limit 10;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/985/1*WaFdAuD_w_T6mhBRrORA4w.png" /></figure><p>We can also run all the queries in the AWS Athena section to better explore the data.</p><p>We checked the data correctness and created an external table for our Redshift cluster. Now, it’s time to create the resulting dashboard using this data.</p><h3>Amazon QuickSight</h3><p>The below article explains how to create an Amazon QuickSight account, connect to Redshift and Athena, and create data sets using those. In the end, it also explains how to use the QuickSight page efficiently.</p><p><a href="https://medium.com/@dogukannulu/creating-quicksight-dashboards-using-amazon-redshift-and-athena-b503a8f937bf">https://medium.com/@dogukannulu/creating-quicksight-dashboards-using-amazon-redshift-and-athena-b503a8f937bf</a></p><p>For the dashboard, we can use both Athena and Redshift (even though they include exactly the same data, we can test our knowledge at this point).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/601/1*zChpaoFlqMXKPQzcWF7GAA.png" /></figure><p>The dashboard part depends completely on the use case of the data. I created a sample dashboard as below. You can also see the <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/quicksight_dashboard/books_dashboard.pdf">resulting Books Dashboard here</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r_8qXOWitVgBdd3WPNb5NA.png" /></figure><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3c3c00b99de0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Cloud Engineering Project — Part 2 — AWS Glue, Lambda, Kinesis, Redshift, Athena, QuickSight…]]></title>
            <link>https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-2-aws-glue-lambda-kinesis-redshift-athena-quicksight-c72b26876592?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/c72b26876592</guid>
            <category><![CDATA[aws-glue]]></category>
            <category><![CDATA[data]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Sun, 28 Jun 2026 15:31:44 GMT</pubDate>
            <atom:updated>2026-07-05T14:35:15.798Z</atom:updated>
            <content:encoded><![CDATA[<h3>AWS Cloud Engineering Project — Part 2 — AWS Glue, Lambda, Kinesis, Redshift, Athena, QuickSight, S3, EC2, Spark</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1001/1*xoJx8bM-v3_vTKKksew7CA.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline">GitHub - dogukannulu/aws_end_to_end_streaming_pipeline: An AWS Data Engineering End-to-End Project (Glue, Lambda, Kinesis, Redshift, QuickSight, Athena, EC2, S3)</a></p><p>You may see the part 1 (Data ingestion) below:</p><p><a href="https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-1-aws-glue-lambda-kinesis-redshift-athena-quicksight-649fc4d7e0dc">https://medium.com/@dogukannulu/aws-cloud-engineering-project-part-1-aws-glue-lambda-kinesis-redshift-athena-quicksight-649fc4d7e0dc</a></p><h3>Overview</h3><p>In the previous part, we streamed data into Amazon Kinesis and uploaded the resulting data to the S3 bucket. In this part, we are going to create a Lambda function first. This function will be triggered by the S3 object upload. Once triggered, it will convert the data to parquet and upload the data to another S3 bucket.</p><p>We are going to create a Glue workflow which will include a Glue Crawler, a Glue ETL Job, and triggers. We are going to run this workflow once Lambda uploads the parquet file to the S3 bucket (either on schedule or manually, depending on the use case). The crawler will create a new Glue table and the ETL job will clean the dirty data. In the end, it will upload the resulting data to a different S3 bucket and will create a Glue table accordingly.</p><h3>Amazon S3 Bucket</h3><p>The first thing we have to do is to create an S3 bucket. We are going to use this bucket for the Lambda function to upload the resulting parquet data. From the S3 dashboard -&gt; Create bucket:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CRazupU0gPzkJFwjFrXoNA.png" /></figure><p>We can leave all other parameters as default. In the end, we can click on Create bucket (<strong>aws-glue-dirty-books-parquet-dogukan-ulu </strong>for this project).</p><h3>Lambda Function</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/tree/main/lambda_function">aws_end_to_end_streaming_pipeline/lambda_function at main · dogukannulu/aws_end_to_end_streaming_pipeline</a></p><p>We are going to create the Lambda function first. The best and most stable way of creating a Lambda function is using a Docker container image. In the below article, you may see the steps for creating the Lambda function as a Docker container.</p><p><a href="https://medium.com/@dogukannulu/how-to-create-amazon-lambda-function-with-the-container-image-dockerfile-6ab7927f5b99">How to Create Amazon Lambda Function with the Container Image (Dockerfile)</a></p><p>We will use <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/lambda_function/Dockerfile">this Dockerfile</a> and <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/lambda_function/requirements.txt">requirements file</a> for this project. <br>One of the significant points for the Lambda function is creating an endpoint for Amazon S3. You can check the below article for how to create a VPC and create an S3 endpoint. You may either create a dedicated new VPC or use the default VPC. We have to add the endpoint for each. I explained the process in detail below.</p><p><a href="https://medium.com/@dogukannulu/establishing-a-vpc-for-amazon-s3-lambda-rds-and-ec2-8f3aa53b5429">Establishing a VPC for Amazon S3, Lambda, RDS and EC2</a></p><p>We should also have a proper IAM role that contains necessary S3 and CloudWatch permissions (<strong>AmazonS3FullAccess</strong> and <strong>CloudWatchFullAccess</strong> for example).</p><p>The other thing is having a certain security group. That doesn’t matter too much since we won’t need additional inbound rules.</p><p>To conclude the preparation part, we should have the following ones ready:</p><ul><li>IAM role with necessary S3 and CloudWatch permissions (<strong>AmazonS3FullAccess</strong> and <strong>CloudWatchFullAccess</strong>)</li><li>The VPC with an S3 endpoint</li><li>Security group dedicated to our Lambda function</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/576/1*sypDmFxwnUmNmBhsgzRstg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ue5rmvRAaiXQBLLj6roR4w.png" /></figure><p>After having all these ready, we can now walk through the Lambda function itself. We are going to create a class to obtain the objects coming from the source S3 bucket.</p><pre>class S3Loader:<br>    def __init__(self):<br>        self.s3 = boto3.client(&#39;s3&#39;)<br><br>    def load_df_from_s3(self, bucket_name, key):<br>        &quot;&quot;&quot;<br>        Read JSON from an S3 bucket &amp; load into a pandas dataframe<br>        &quot;&quot;&quot;<br>        print(&quot;Starting S3 object retrieval process...&quot;)<br>        <br>        try:<br>            get_response = self.s3.get_object(Bucket=bucket_name, Key=key)<br>            print(&quot;Object retrieved from S3 bucket successfully&quot;)<br>        except ClientError as e:<br>            print(f&quot;S3 object cannot be retrieved: {e}&quot;)<br>            return None<br>                <br>        json_data = get_response[&#39;Body&#39;].read().decode(&#39;utf-8&#39;)<br>        <br>        json_data_separated = json_data.replace(&#39;}{&#39;, &#39;},{&#39;)<br>        json_data_final = f&quot;[{json_data_separated}]&quot;<br>        json_objects = json.loads(json_data_final)<br>        df = pd.DataFrame(json_objects)<br>        return df</pre><p>Since the JSON data will be combined by Firehose and uploaded directly to the S3 bucket, the lambda function will modify that data and create the proper JSON data out of it. In the end, it will create a pandas data frame. After getting the data, we are going to create a parquet buffer out of it.</p><pre>class S3Uploader:<br>    def __init__(self):<br>        self.s3 = boto3.client(&#39;s3&#39;)<br>        self.target_s3_bucket = &quot;aws-glue-dirty-books-parquet-dogukan-ulu&quot;<br>        self.target_s3_key = &quot;dirty_books_parquet/books.parquet&quot;<br>    <br>    def upload(self, buffer):<br>        &quot;&quot;&quot;<br>        Upload Parquet data from memory buffer to S3<br>        &quot;&quot;&quot;<br>        try:<br>            self.s3.put_object(Body=buffer.getvalue(), Bucket=self.target_s3_bucket, Key=self.target_s3_key)<br>            print(&quot;Parquet file uploaded into S3 successfully&quot;)<br>        except Exception as e:<br>            print(f&quot;Error occured while uploading parquet into S3: {e}&quot;)</pre><p>We also defined the target S3 bucket with prefix and key. So, our parquet data will be uploaded to <strong>s3://aws-glue-dirty-books-parquet-dogukan-ulu/dirty_books_parquet/books.parquet.</strong></p><p>We should create a <strong>lambda_handler</strong> function since our Dockerfile will check that method.</p><pre>def lambda_handler(event, context):<br>    try:<br>        bucket = event[&#39;Records&#39;][0][&#39;s3&#39;][&#39;bucket&#39;][&#39;name&#39;]<br>        key = event[&#39;Records&#39;][0][&#39;s3&#39;][&#39;object&#39;][&#39;key&#39;]<br>        print(f&quot;S3 bucket is obtained from the event: {bucket}&quot;)<br>        print(f&quot;Object key is obtained from the event: {key}&quot;)<br>        s3_loader = S3Loader()<br>        df = s3_loader.load_df_from_s3(bucket_name=bucket, key=key)<br>        <br>        parquet_buffer = io.BytesIO()<br>        df.to_parquet(parquet_buffer, index=False)<br>        print(&quot;JSON has been converted into parquet&quot;)<br>        <br>        s3_uploader = S3Uploader()<br>        s3_uploader.upload(parquet_buffer)<br>        return {<br>            &#39;statusCode&#39;: 200,<br>            &#39;body&#39;: json.dumps(&#39;Parquet conversion and upload successful&#39;)<br>        }<br>        <br>    except Exception as e:<br>        print(f&quot;An error occurred: {e}&quot;)<br>        return {<br>            &#39;statusCode&#39;: 500,<br>            &#39;body&#39;: json.dumps(&#39;An error occurred during processing&#39;)<br>        }</pre><p>In this method, we get the data from the S3 event because we want this function to be triggered by the S3 object upload. You can see a <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/lambda_function/sample_event.json">sample S3 event here</a>. Then, we created a parquet buffer and uploaded it to the target S3 bucket. Now, we have our Lambda function ready. After deploying our function to Amazon ECR following the instructions in the above article, we can use it to create our Lambda function.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KhnSE_MOUzQ4YT11ZVdG8A.png" /></figure><p>We should choose <strong>Container Image</strong> as the creation method. We can name our function as <strong>json-to-parquet-lambda-function</strong>. We should copy the URI of the container image located in the ECR and paste it under the <strong>Container image URI</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tDizzQMGEbrn8xLkMKH8aQ.png" /></figure><p>We can choose the architecture according to our local machine and select the suitable IAM role we just created. Then, we can create our Lambda function.</p><p>After it is created, we should set the timeout as 1 minute from the basic settings (We can set a lower limit but just in case). We should also modify the VPC settings. We should choose the VPC that includes the S3 endpoint we recently created. We can choose as many subnets as we want and the dedicated security group for our function.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-9eJuvTM0e84w3dyE6-fZA.png" /></figure><p>Since we don’t have any environmental variables, we don’t have to modify that part. We are going to add the S3 trigger now.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/814/1*SAJVSB-Q9RkV1pAXY1T5Aw.png" /></figure><p>We should choose the source bucket as the one Firehose will upload the streaming data. We can define the prefix as<strong> json_data/</strong> since the data will be uploaded to that directory.</p><p>After setting the trigger, we have our Lambda function ready now. Once triggered, we should see the logs of the script as below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LexIxjz55yG5l5gWH37AcQ.png" /></figure><p>We should also see the parquet file in the S3 bucket as below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/888/1*Ao3aOJtr1MfWwwDEYiFwrw.png" /></figure><p>We can decide on the directory to which we want to upload our parquet file. Once uploaded, we can query our file using <strong>S3 Select</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/619/1*AWxqPgk3J-a6SM8LJMmQfw.png" /></figure><p>The result will be something as below. You can also see the <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/glue_etl_job/dirty_books_data.parquet">resulting parquet file here</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TZWycBhn26mdCzkYHfeKQA.png" /></figure><h3>AWS Glue Crawler</h3><p>Our next plan is to take the dirty parquet file uploaded by the Lambda function by a Crawler and create a corresponding Glue table. We should have an IAM role that has an <strong>AmazonS3FullAccess</strong> policy. We can use it both for the crawler and the ETL job. We are going to create our Crawler first.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/998/1*IqhsWeyrYIHDM42-XYaIUg.png" /></figure><p>We can name it as <strong>books-parquet-to-glue-table.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/606/1*VcMqTgowznE5aZkMUWLh8Q.png" /></figure><p>If we want our crawler to crawl all the files, we can define the location as a directory under a certain S3 bucket.</p><p>We can choose a Glue Data Catalog table as well. But if our table hasn’t been created yet, we have to choose <strong>Not yet</strong> as the first option. We are going to define our S3 location. Depending on our use case, we can choose <strong>Crawl all sub-folders</strong> or we can define it so that it crawls only the newly uploaded files under the S3 directory. If new files are uploaded into the S3 bucket every time we run our pipeline and we only want to crawl them, we can choose <strong>Crawl new sub-folders only</strong> as above.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/997/1*IXFl1oO-LD4_HZSaktBeAg.png" /></figure><p>We should choose the IAM role that the Crawler will be using so that it includes necessary S3 permissions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1022/1*HThzFc91yTw9P-rngIJy2Q.png" /></figure><p>We should also choose a target database in which our resulting Glue table will be located.</p><p>We can choose a prefix for the resulting table. If we don’t choose, the name of our target table will be the same as the source S3 directory. We should choose the schedule as O<strong>n demand</strong> since it will be triggered by a trigger in the workflow.</p><p>After defining all the parameters, we can create our Crawler. We have to create the Glue ETL Job since it will also be needed while creating our workflow. Our crawler will create a Glue table named <strong>dirty_books_parquet</strong> at the end.</p><h3>AWS Glue ETL Job</h3><p><a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/tree/main/glue_etl_job">aws_end_to_end_streaming_pipeline/glue_etl_job at main · dogukannulu/aws_end_to_end_streaming_pipeline</a></p><p>Now, it’s time to create our Glue ETL job. It will modify the dirty parquet data uploaded by the Lambda function and create a resulting clean parquet file. It will upload that file to an S3 bucket named <strong>aws-glue-clean-books-parquet-dogukan-ulu</strong> and create a corresponding Glue table <strong>clean_parquet</strong> under the database <strong>glue-etl-books-parquet-dogukan-ulu</strong>.</p><p>First of all, we have to create our Glue ETL job using a Spark script.</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/0*Vg23Mr3jOOEYGRi1.png" /></figure><p>After creating the script, we can set the parameters as 2 DPU and the least powerful worker type since our workload will not be that much. We should name our job as well. Now, we can go through our ETL job script. First of all, the imports come as default in Glue ETL jobs. We should add the last two lines.</p><pre>import sys<br>from awsglue.transforms import *<br>from awsglue.utils import getResolvedOptions<br>from pyspark.context import SparkContext<br>from awsglue.context import GlueContext<br>from awsglue.job import Job<br>from awsglue.dynamicframe import DynamicFrame<br>from pyspark.sql import functions as F<br>from pyspark.sql.types import IntegerType, FloatType</pre><p>There are more default parameters as below that start Spark session.</p><pre>args = getResolvedOptions(sys.argv, [&#39;JOB_NAME&#39;])<br>sc = SparkContext()<br>glueContext = GlueContext(sc)<br>spark = glueContext.spark_session<br>job = Job(glueContext)<br>job.init(args[&#39;JOB_NAME&#39;], args)</pre><p>We should define a dictionary so that we can convert the <strong>num_reviews</strong> column from string to integer.</p><pre>num_review_mapping = {<br>    &quot;One&quot;: 1, &quot;Two&quot;: 2, &quot;Three&quot;: 3, &quot;Four&quot;: 4, &quot;Five&quot;: 5<br>}</pre><p>Since our Glue ETL job will run after the crawler creates a new Glue table <strong>dirty_books_parquet</strong>, we can read the data in that table using a Glue dynamic frame. Then, we are going to convert it to a Spark data frame as below.</p><pre>def create_initial_df():<br>    glue_dynamic_frame_initial = glueContext.create_dynamic_frame.from_catalog(database=&#39;glue-etl-books-parquet-dogukan-ulu&#39;, table_name=&#39;dirty_books_parquet&#39;)<br>    df = glue_dynamic_frame_initial.toDF()<br><br>    return df</pre><p>We are going to drop the pound symbol from the <strong>price</strong> column (also convert it to float) and convert the <strong>num_reviews</strong> column to an integer instead of a string.</p><pre>def map_num_reviews(review):<br>    return num_review_mapping.get(review, review)<br><br>map_num_reviews_udf = F.udf(map_num_reviews, IntegerType())<br><br># Define the UDF for price formatting<br>def format_price(price):<br>    price = price.replace(&quot;£&quot;, &quot;&quot;).replace(&quot;,&quot;, &quot;&quot;)<br>    return round(float(price), 2)<br><br>format_price_udf = F.udf(format_price, FloatType())</pre><p>In the end, we are going to create our final data frame. We should drop the <strong>upc</strong> column since it’s unnecessary. We will also convert the <strong>availability</strong> column from string to integer depending on the column content. We will also convert our Spark data frame to a Glue dynamic frame to be able to write it to a Glue table.</p><pre>df_final = df.withColumn(&quot;price&quot;, format_price_udf(&quot;price&quot;)) \<br>             .drop(&quot;upc&quot;) \<br>             .withColumn(&quot;num_reviews&quot;, map_num_reviews_udf(&quot;num_reviews&quot;)) \<br>             .withColumn(&quot;availability&quot;, F.when(df[&quot;availability&quot;] == &quot;In stock&quot;, 1).otherwise(0))<br><br>glue_dynamic_frame_final = DynamicFrame.fromDF(df_final, glueContext, &quot;glue_etl&quot;)</pre><p>Last but not least, we are going to upload our resulting clean parquet file to the S3 bucket and create a corresponding Glue table.</p><pre>s3output = glueContext.getSink(<br>  path=&quot;s3://aws-glue-clean-books-parquet-dogukan-ulu/clean_books_parquet&quot;,<br>  connection_type=&quot;s3&quot;,<br>  updateBehavior=&quot;UPDATE_IN_DATABASE&quot;,<br>  partitionKeys=[],<br>  compression=&quot;snappy&quot;,<br>  enableUpdateCatalog=True,<br>  transformation_ctx=&quot;s3output&quot;,<br>)<br><br>s3output.setCatalogInfo(<br>  catalogDatabase=&quot;glue-etl-books-parquet-dogukan-ulu&quot;, catalogTableName=&quot;clean_books_parquet&quot;<br>)<br>s3output.setFormat(&quot;glueparquet&quot;)<br>s3output.writeFrame(glue_dynamic_frame_final)<br>job.commit()</pre><p>Our resulting parquet file should look like <a href="https://github.com/dogukannulu/aws_end_to_end_streaming_pipeline/blob/main/glue_etl_job/clean_books_data.parquet">this <strong>clean_books_data.parquet</strong> file</a>.</p><h3>AWS Glue Workflow</h3><p>Now it’s time to create the Glue workflow so that this whole process is automated. You can check the below article to have an insight into how to create a Glue workflow.</p><p><a href="https://medium.com/@dogukannulu/creating-aws-glue-workflows-with-glue-crawler-and-glue-etl-jobs-afd28154ea91">Creating AWS Glue Workflows with Glue Crawler and Glue ETL Jobs</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XaRBzFpRf0h_TS_gKFbfDg.png" /></figure><p>We should have the following components for our workflow:</p><ul><li>An initial trigger. If we want to use this workflow for demo purposes, we can choose it as an on-demand trigger (We can trigger it manually). If we want it to be automatic for an automated streaming pipeline, we should set it as Schedule and decide on the cron expression as below.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/678/1*n7jGThDttmMjvTvTH3AxSg.png" /></figure><ul><li>We should have our crawler as the second component.</li><li>We should add another trigger after the crawler with ANY notation.</li><li>In the end, we will have our Glue ETL job.</li></ul><p>This workflow will triggered initially. Then, our crawler will create a corresponding Glue table from the dirty parquet file. Once created, our Glue ETL job will start running. It will clean the data and create a resulting clean parquet file. It will upload it to the S3 bucket and create a corresponding Glue table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*n6Uor66kFjhs8GTcP5-uNQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vgGgPnyQOw7K4wflHPD6AA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/972/1*PQ0l1ui6VJPos-YSqU1QLA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/959/1*05xpZbT7wPpD-c1mNKUMZA.png" /></figure><p>In this part of the article, we created a new Lambda function using a Docker container image. Once we upload the JSON data coming from the Firehose, it is triggered. It converts that data and uploads it as a parquet file into another S3 bucket. Once it is uploaded, our Glue workflow can start running. It includes the Glue crawler and Glue ETL job. In the end, we cleaned the parquet data, uploaded it to S3, and created a corresponding Glue table.</p><p>In the last part, we are going to check if the data is uploaded correctly. Then, we will create the corresponding tables in Redshift and Athena. In the end, we are going to create a QuickSight dashboard using this data.</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c72b26876592" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Production-Grade Text-to-SQL Agent with Claude Code, LangGraph, Langfuse, FastAPI and Qdrant]]></title>
            <link>https://medium.com/@dogukannulu/production-grade-text-to-sql-agent-with-claude-code-langgraph-langfuse-fastapi-and-qdrant-91a3d7beec49?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/91a3d7beec49</guid>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[data]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[naturallanguageprocessing]]></category>
            <category><![CDATA[langchain]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Wed, 24 Jun 2026 13:04:42 GMT</pubDate>
            <atom:updated>2026-07-05T14:35:38.065Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZK-OSy49um2uE7dlBkPccQ.png" /></figure><h3>Repository</h3><p><a href="https://github.com/dogukannulu/text-to-sql">https://github.com/dogukannulu/text-to-sql</a></p><h3>Tech Stack</h3><p>Before diving in, here is every tool, framework, and package used across the project:</p><h4>LLM &amp; Agent</h4><ul><li>Claude Sonnet 4.6 (Anthropic) — SQL generation, self-correction, result explanation</li><li>Claude Haiku 4.5 (Anthropic) — LLM-as-judge for hallucination evaluation</li><li>LangChain 0.3 — LLM wrappers, prompt templates, structured output parsing</li><li>LangGraph 0.2 — stateful agent graph (7 nodes, conditional routing)</li><li>langchain-anthropic 0.2 — Claude integration for LangChain</li></ul><h4>Embeddings &amp; Vector Search</h4><ul><li>Voyage AI voyage-3–1024-dimensional text embeddings</li><li>langchain-voyageai — LangChain wrapper for Voyage AI</li><li>Qdrant — vector store (2 collections: schema + knowledge base)</li><li>qdrant-client 1.8 — Python SDK for Qdrant</li></ul><h4>API &amp; Backend</h4><ul><li>FastAPI 0.110 — REST API (POST /query, GET /history, POST /schema/refresh)</li><li>Uvicorn 0.29 — ASGI server</li><li>python-multipart — multipart form support for FastAPI</li><li>SQLModel 0.0.16 — ORM (combines SQLAlchemy + Pydantic)</li><li>psycopg2-binary 2.9 — PostgreSQL adapter</li><li>Alembic 1.13 — database migrations</li><li>pydantic 2.6 — data validation and serialisation</li><li>pydantic-settings 2.2 — settings management (.env)</li><li>sqlglot 23.0 — SQL parser for syntax validation (no DB round-trip)</li></ul><h4>Frontend</h4><ul><li>Streamlit 1.33 — chat UI, dataframe display, schema sidebar</li></ul><h4>Observability &amp; Tracing</h4><ul><li>Langfuse (Cloud) — LLM observability: traces, spans, token usage, scores</li><li>langfuse 4.x Python SDK — @observe decorator, score_current_trace()</li></ul><h4>Evaluation</h4><ul><li>deepeval / GEval — evaluation harness framework</li><li>anthropic Python SDK — direct Anthropic API calls for LLM-as-judge</li></ul><h4>Infrastructure &amp; Config</h4><ul><li>Docker + Docker Compose — all 6 services in one compose file</li><li>PostgreSQL 16 (postgres:16-alpine) — primary data store + Langfuse metadata DB</li></ul><h4>MCP Server</h4><ul><li>mcp Python SDK (FastMCP) — MCP server exposing 3 tools to Claude Code</li><li>requests 2.31 — HTTP client for MCP server → backend calls</li></ul><h4>Testing</h4><ul><li>pytest 8.0 — test runner</li><li>pytest-asyncio 0.23 — async test support</li><li>httpx 0.27 — async HTTP client for integration tests</li></ul><h4>Developer Tooling</h4><ul><li>ruff 0.3 — linter and formatter</li><li>mypy 1.9 — static type checker</li><li>python-dotenv — .env file loading</li></ul><h3>Why I built a Text-to-SQL agent from scratch</h3><p>Whenever someone wants to easily build queries with pure text, NLQ or text to SQL comes into our minds. That’s why I wanted to know whether a modern LLM stack could close that loop entirely — not just generate SQL, but execute it, explain the results in plain English, and recover gracefully when the query is wrong.</p><p>So, I built a production-grade agent with a RAG pipeline, a 7-node LangGraph state machine, full observability, a REST API, a Streamlit UI, an MCP server for Claude Code, and a GEval evaluation harness. The database is fictional (UDogRetail — a dog-products e-commerce company), but every architectural decision reflects something I’d do on a real project.</p><p>This article walks through how I built it, every non-obvious decision I made, and everything that went wrong.</p><p>The full code is on <a href="https://github.com/dogukannulu/text-to-sql">GitHub</a>: clone it, run docker compose up, and it works in under five minutes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ENbEpd3gVuDHScQ8atrRuQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fSSzjMLX0vbQtzbDCIYnJQ.png" /></figure><h3>1. The UDogRetail dataset — designing a realistic test environment</h3><p>Real databases and semantic layers are messy. They have tables with ambiguous names, columns with legacy conventions, and business rules that exist only in someone’s head. I wanted a test environment that reflected this complexity without needing access to production data.</p><p>UDogRetail has five business tables:</p><p><strong>customers </strong>— 1,000 customer records with full_name, email, country, plan_tier</p><p><strong>orders</strong> — 4,500 orders with a status lifecycle (pending → delivered → refunded)</p><p><strong>order_items</strong> — ~10,000 line items with product categories and discount percentages</p><p><strong>payments</strong> — 4,300 payment attempts across four providers (Stripe, PayPal, Klarna, Adyen)</p><p><strong>subscriptions</strong> — 686 subscription contracts with monthly_fee and cancellation tracking</p><p>I generated seed data with a Python script (<a href="https://github.com/dogukannulu/text-to-sql/blob/main/scripts/generate_seed.py">scripts/generate_seed.py</a>) using random.seed(42) so every fresh clone produces identical data. The data has intentional quirks that make it a good test: mixed currencies (GBP, EUR, USD), nullable shipped_at columns, a multi-table revenue calculation requiring a JOIN across three tables, and a full_name column — not first_name/last_name — that the LLM reliably tries to hallucinate.</p><p>I also wrote nine knowledge-base markdown documents covering KPI definitions (MRR, churn rate, ARPU), business rules (how status = ‘active’ filters apply to subscriptions), and data quality notes. These matter because some questions are unanswerable without domain context — “what is our MRR?” requires knowing that MRR = SUM(monthly_fee) WHERE status = ‘active’, not just a count of subscriptions.</p><h3>2. Architecture overview — how all the pieces fit together</h3><p>The system has six Docker services, all wired together in a single <a href="https://github.com/dogukannulu/text-to-sql/blob/main/docker-compose.yml">docker-compose.yml</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AojxgVNEP1cErHWrAD8T5g.png" /></figure><p>The data flow:</p><ul><li>User → Streamlit (8501) → POST /query (FastAPI, 8000) → LangGraph Agent <strong>OR</strong> Claude Code → MCP Server → POST /query (FastAPI, 8000) → LangGraph Agent</li><li>RETRIEVE (Qdrant)</li><li>GENERATE (Claude)</li><li>VALIDATE (<a href="https://github.com/tobymao/sqlglot">sqlglot</a>)</li><li>EXECUTE (PostgreSQL)</li><li>EXPLAIN ←── success</li><li>CORRECT ←── error (up to 3 retries)</li><li>CLARIFY ←── retries exhausted</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Cf-k0e4B_Ujl-Fxg__pwg.png" /></figure><p>Every query run is traced in Langfuse and saved to a query_run table in PostgreSQL. The MCP server exposes <strong>query_tool</strong>, <strong>schema_tool</strong>, and <strong>history_tool</strong> to Claude Code as a stdio subprocess.</p><h4>The tech stack I chose and why:</h4><p><strong>LLM:</strong> Claude Sonnet 4.6 (Anthropic) — best structured-output reliability in my testing</p><p><strong>Agent:</strong> LangGraph 0.2 — explicit state machine, every transition is readable code</p><p><strong>Embeddings:</strong> Voyage AI voyage-3 — strong retrieval quality, 1024-dimensional vectors</p><p><strong>Vector store:</strong> Qdrant — simple Docker setup, fast cosine similarity search</p><p><strong>API:</strong> FastAPI + Uvicorn — async, Pydantic validation built-in</p><p><strong>Frontend:</strong> Streamlit — functional chat UI in ~300 lines</p><p><strong>Observability:</strong> Langfuse — token tracking + span-level latency per node</p><p><strong>Evaluation:</strong> GEval / deepeval — LLM-as-judge for hallucination scoring</p><p><strong>Config:</strong> pydantic-settings — .env locally, AWS SSM Parameter Store in production</p><h3>3. Building the RAG pipeline — schema + document retrieval</h3><p>The most important architectural decision in the whole project: ground the SQL generator in retrieved schema context, not LLM memory.</p><p>Without RAG, Claude generates SQL from training data. It knows generic SQL patterns perfectly, but it doesn’t know that UDogRetail uses full_name instead of first_name/last_name, or that MRR is defined as SUM(monthly_fee) WHERE status = ‘active’. Every query that requires either of these will hallucinate.</p><h4>Schema indexing</h4><p>I wrote a schema crawler (<a href="https://github.com/dogukannulu/text-to-sql/blob/main/backend/app/rag/crawler.py">app/rag/crawler.py</a>) that queries <em>INFORMATION_SCHEMA</em> to extract table names, column names, data types, nullable flags, and foreign key relationships. This runs at startup via the FastAPI lifespan handler.</p><p>Each table becomes one chunk in Qdrant’s <strong>udogretail_schema</strong> collection — five vectors total, one per table. The chunk format is deliberately verbose:</p><p><strong>Table:</strong> customers</p><p><strong>Description:</strong> Core customer registry. One row per registered user account.</p><p><strong>Columns:</strong></p><ul><li>id (uuid, NOT NULL) — Primary key</li><li>full_name (character varying, NOT NULL)</li><li>email (character varying, NOT NULL)</li><li>country (character varying, NOT NULL)</li><li>plan_tier (varchar, NOT NULL)</li><li>created_at (timestamptz, NOT NULL)</li></ul><p><strong>Foreign keys:</strong> none</p><p>The full column name, type, and nullability are in every chunk. When a question is embedded and searched, the schema chunks that come back give Claude the exact column names — no guessing allowed.</p><h4>Knowledge base indexing</h4><p>Nine markdown documents live in <a href="https://github.com/dogukannulu/text-to-sql/tree/main/backend/knowledge_base">backend/knowledge_base/</a>. They cover things the schema can’t express: what “active subscription” means, how to calculate MRR, which currency to use when grouping revenue, and known data quality issues.</p><p>These are split with RecursiveCharacterTextSplitter at 400 tokens with 40-token overlap, producing ~100 chunks in the <strong>udogretail_docs</strong> collection.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ennWXEshmQ56ediHLfUTuw.png" /><figcaption>Qdrant Collections</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RT-qoHyHKJRQiy2drBummg.png" /><figcaption>FastAPI Qdrant schema status endpoint</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/268/1*lKygns75OJlQ8m3o2A65Yw.png" /><figcaption>Schema Browser in Streamlit UI</figcaption></figure><h4>Retrieval</h4><p>At query time, retrieve_context() embeds the user’s question with voyage-3 and searches both collections (<strong>udogretail_schema</strong> and <strong>udogretail_docs</strong>) in parallel using ThreadPoolExecutor(max_workers=2). The top-3 results from each collection are merged and sorted by relevance score — always the most relevant schema tables and the most relevant business rules, delivered together to the <strong>GENERATE</strong> node.</p><h3>4. The LangGraph agent — nodes, state, and self-correction</h3><p>The agent is a StateGraph with seven nodes. Every node receives and returns an AgentState TypedDict — 17 fields covering the question, retrieved context, generated SQL, execution result, error history, and Langfuse trace ID.</p><pre>class AgentState(TypedDict):<br>  question: str<br>  session_id: str<br>  retrieved_schema: list[str]<br>  retrieved_docs: list[str]<br>  generated_sql: Optional[str]<br>  sql_reasoning: Optional[str]<br>  sql_assumptions: list[str]<br>  sql_confidence: float<br>  validation_error: Optional[str]<br>  execution_result: Optional[ExecutionResult]<br>  execution_error: Optional[str]<br>  retry_count: int<br>  correction_history: list[CorrectionRecord]<br>  needs_clarification: bool<br>  clarification_message: Optional[str]<br>  final_explanation: Optional[str]<br>  langfuse_trace_id: Optional[str]</pre><h4>GENERATE</h4><p>The GENERATE node calls Claude with a structured output schema (SQLGenerationResponse) using LangChain’s with_structured_output(model, include_raw=True). The include_raw=True flag is crucial — it gives back both the parsed Pydantic model AND the raw AIMessage with usage_metadata, so I can log token counts to Langfuse without a second API call.</p><p>The system prompt has one rule that saved me hours of debugging:</p><p><strong>CRITICAL:</strong> Use ONLY the exact column names shown in the schema above.</p><p>Never guess or invent column names. If a name feels intuitive (e.g. first_name, last_name) but is not in the schema, it does NOT exist — use what is shown (e.g. full_name).</p><p>Without this constraint, Claude reliably generates c.first_name || ‘ ‘ || c.last_name for any customer name query. The column is full_name. The “CRITICAL:” label is not decoration — Claude follows explicitly labelled constraints more reliably than softly worded ones.</p><h4>VALIDATE → EXECUTE</h4><p>The VALIDATE node runs sqlglot.parse(sql, dialect=”postgres”) before touching the database. Syntax errors caught here are cheaper than round-tripping to PostgreSQL.</p><p>The EXECUTE node (<a href="https://github.com/dogukannulu/text-to-sql/blob/main/backend/app/tools/pg_executor.py">app/tools/pg_executor.py</a>) enforces read-only access with a FORBIDDEN_KEYWORDS check:</p><pre>FORBIDDEN_KEYWORDS = frozenset({<br>&quot;INSERT&quot;, &quot;UPDATE&quot;, &quot;DELETE&quot;, &quot;DROP&quot;,<br>&quot;TRUNCATE&quot;, &quot;ALTER&quot;, &quot;CREATE&quot;, &quot;GRANT&quot;, &quot;REVOKE&quot;<br>})</pre><p>Every token in the SQL is checked against this set before execution. The database also runs with SET LOCAL statement_timeout so runaway queries can’t hang the server.</p><h4>The self-correction loop</h4><p>The routing after EXECUTE is the heart of the agent:</p><pre>def route_after_execute(state: AgentState) -&gt; str:<br>  if state[&quot;execution_error&quot;] is None:<br>    return &quot;explain&quot;<br>  if state[&quot;retry_count&quot;] &gt;= settings.max_retries:<br>    return &quot;clarify&quot;<br>    return &quot;correct&quot;</pre><p>CORRECT re-prompts Claude with the original question, full schema context, the failed SQL, and the PostgreSQL error message. The CorrectionRecord is appended to correction_history so each retry knows what was tried before. CORRECT always routes back through VALIDATE — corrected SQL still needs a syntax check before hitting the database.</p><p>After MAX_RETRIES (default: 3), the CLARIFY node generates a user-friendly message with specific suggestions: rephrase with exact column names, break the question into parts, specify a currency for monetary questions.</p><h3>5. Making it production-ready — FastAPI, Docker, Terraform</h3><h4>The API</h4><p>The API has three route groups — /query, /history, and /schema. All I/O is typed with Pydantic:</p><ul><li><strong>QueryRequest:</strong> question (5–500 chars), session_id (UUID string)</li><li><strong>QueryResponse:</strong> full result including SQL, rows, explanation, retry count, succeeded flag</li><li><strong>QueryRunSummary / QueryRunDetail:</strong> history endpoint shapes</li></ul><p>Rate limiting (max 10 requests/minute per session_id) protects against runaway clients. CORS allows localhost:8501 (Streamlit) and localhost:8080 (MCP server) in local mode.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SMa3986i1ae7eU38F8omkA.png" /><figcaption>All FastAPI endpoints</figcaption></figure><h4>Docker</h4><p>All six services are defined in docker-compose.yml. The backend Dockerfile runs Alembic migrations before starting uvicorn:</p><pre>#!/bin/bash<br># backend/start.sh<br>set -e<br>echo &quot;==&gt; Running Alembic migrations…&quot;<br>cd /app/backend &amp;&amp; alembic upgrade head<br>echo &quot;==&gt; Starting uvicorn…&quot;<br>exec uvicorn app.main:app - host 0.0.0.0 - port 8000</pre><p>This guarantees the <strong>query_run</strong>, <strong>correction_attempt</strong>, and <strong>schema_snapshot</strong> tables exist before the first request arrives — no race conditions between migration and serving.</p><h4>Configuration</h4><p>app/config.py uses pydantic-settings BaseSettings for local development and fetches secrets from AWS SSM Parameter Store when APP_ENV=production (in our case, they are kept in .env):</p><pre>class Settings(BaseSettings):<br>  anthropic_api_key: SecretStr<br>  voyage_api_key: SecretStr<br>  postgres_password: SecretStr<br>  langfuse_secret_key: SecretStr</pre><p># All secrets use SecretStr — never logged as plaintext</p><p>The SSM path convention is /udogretail/text2sql/{FIELD_NAME}. Swapping from local to production is a single env var change.</p><h3>6. Observability with Langfuse — tracing every agent run</h3><p>Every POST /query call creates a Langfuse trace. Every node in the agent creates a child span. I used Langfuse SDK v4’s @observe decorator — it wraps functions automatically and attaches them to the active trace context:</p><pre>@observe(name=&quot;generate&quot;, as_type=&quot;generation&quot;)<br>def generate(state: AgentState) -&gt; AgentState:<br># Claude call happens here<br># Langfuse auto-captures input, output, latency</pre><p>For Claude calls, I use with_structured_output(model, include_raw=True) to capture token counts from raw_response.usage_metadata and log them to the active span:</p><pre>lf = get_lf_client()<br>lf.update_current_observation(<br>model=&quot;claude-sonnet-4–6&quot;,<br>usage={&quot;input&quot;: input_tokens, &quot;output&quot;: output_tokens},<br>)</pre><p><strong>The result:</strong> every trace shows retrieve → generate → validate → execute → explain with latency per node and token counts per Claude call. I also log two scores per trace — succeeded (1.0 or 0.0) and retry_count — so I can filter traces by quality in the Langfuse scores panel.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3fg1ECuN8Cz4PG9LoZnNaA.png" /><figcaption>Langfuse scores</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2LP8035UNnkP4R6faoQB1A.png" /><figcaption>Langfuse traces</figcaption></figure><p>Three things I learned about Langfuse SDK v4:</p><p>1. @observe replaces the old CallbackHandler. The v4 SDK dropped langfuse.callback entirely. If you’re migrating, rewrite every LangChain integration with @observe.</p><p>2. docker compose restart doesn’t re-read .env. Use docker compose up -d service to recreate the container with new environment variables.</p><p>3. SDK v4 requires Langfuse Cloud or a v3+ self-hosted server. The langfuse/langfuse:2 Docker image is not compatible with SDK v4’s OTLP ingestion format. For anything new, use Langfuse Cloud.</p><h3>7. Testing — unit tests and integration tests</h3><p>A production agent needs a test suite that runs without thinking about it. I split the tests into two layers: unit tests that run offline in milliseconds, and integration tests that hit the running Docker stack.</p><h4>Unit tests</h4><p>Five unit test files cover the pure-logic components that have no external dependencies.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/unit/test_sql_validator.py">test_sql_validator.py</a> — the most important unit test file. It verifies that validate_sql() correctly rejects every forbidden keyword regardless of case, including keywords buried inside CTEs:</p><pre>@pytest.mark.parametrize(&quot;keyword&quot;, sorted(FORBIDDEN_KEYWORDS))<br>def test_forbidden_keyword_rejected(keyword: str) -&gt; None:<br>  sql = f&quot;{keyword} INTO orders VALUES (&#39;x&#39;)&quot;<br>  result = validate_sql(sql)<br>  assert not result.is_valid<br>  assert keyword in result.error_message<br>  def test_forbidden_keyword_in_cte_still_rejected() -&gt; None:<br>  sql = &quot;WITH x AS (DELETE FROM orders RETURNING id) SELECT * FROM x&quot;<br>  result = validate_sql(sql)<br>  assert not result.is_valid</pre><p>This is the security boundary of the entire agent — if validate_sql lets a write statement through, it hits PostgreSQL. The parameterised test ensures every keyword in FORBIDDEN_KEYWORDS is tested individually, including case-insensitive variants.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/unit/test_crawler.py">test_crawler.py</a> — uses a mock SQLModel session to verify crawl_schema() returns the correct SchemaMetadata structure without touching a real database.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/unit/test_chunker.py">test_chunker.py</a> — verifies that chunk_schema() produces one chunk per table and that the chunk text contains the table name, column names, and types.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/unit/test_config.py">test_config.py</a> — verifies that get_settings() loads correctly from environment variables and that all secret fields are SecretStr instances (never plain strings).</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/unit/test_agent_state.py">test_agent_state.py </a>— verifies that AgentState can be instantiated with all 17 fields correctly typed.</p><p>Run unit tests with no Docker required:</p><pre>pytest tests/unit/ -v</pre><h4>Integration tests</h4><p>Three integration test files run against the live Docker stack — they make real HTTP requests to <a href="http://localhost:8000.">http://localhost:8000.</a></p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/integration/test_query_endpoint.py">test_query_endpoint.py</a> — six tests covering the full /query flow: HTTP 200, correct response shape, succeeded=True for a simple question, session_id round-trip, HTTP 422 for a question under 5 characters, and that a query is saved to history.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/integration/test_schema_endpoints.py">test_schema_endpoints.py </a>— verifies GET /schema/status returns vector counts for both Qdrant collections and POST /schema/refresh returns HTTP 200.</p><p><a href="https://github.com/dogukannulu/text-to-sql/blob/main/tests/integration/test_history_endpoint.py">test_history_endpoint.py</a> — verifies GET /history and GET /history/{id} return the correct shapes.</p><p>The design principle: integration tests assert on structure, not values. assert data[“succeeded”] is True and assert “SELECT” in data[“sql”].upper() — not assert data[“result”][0][“count”] == 1000. Values change when the seed data changes; structure doesn’t.</p><p># Docker stack must be running</p><pre>pytest tests/integration/ -v</pre><p>20/20 integration tests pass against the live stack.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lGfsNPt6fcMLVAzKYn6bKQ.png" /><figcaption>All integration test passes</figcaption></figure><h3>8. Evaluating the agent with GEval</h3><p>I built a 32-case golden test set covering three complexity levels and ran three metrics:</p><p><strong>SQL Correctness</strong> — does the query return the expected result columns?</p><p><strong>Schema Faithfulness</strong> — does the generated SQL reference only tables and columns that actually exist in the schema? Checked by parsing the SQL with sqlglot and comparing every identifier against the live schema.</p><p><strong>Hallucination </strong>— is the plain-English explanation faithful to the actual query results? Scored by an LLM-as-judge using claude-haiku-4–5:</p><pre>prompt = f&quot;&quot;&quot;<br>Score from 0.0 to 1.0 whether this explanation is faithful to the results.<br>Results: {json.dumps(rows[:5])}<br>Explanation: {explanation}<br>Return only JSON: {{&quot;score&quot;: float, &quot;reasoning&quot;: str}}<br>&quot;&quot;&quot;</pre><p>Running the harness:</p><pre>python -m evaluation.harness --complexity simple<br>python -m evaluation.harness --limit 10</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Av648DtYi9mZ9pv-IVFJGg.png" /><figcaption>Example GEval evaluation results</figcaption></figure><p>The test set includes five “trick” questions that require knowledge-base docs to answer correctly — questions like “what is our MRR?” (requires the business rule status = ‘active’) and “show customers with duplicate payments” (requires knowing this data quality issue is documented in the knowledge base). These are the questions that fail without RAG and pass with it.</p><h3>9. The MCP server — making it a Claude Code citizen</h3><p>We can invoke the FastAPI endpoint via Streamlit. But, we can also do the same using an MCP server using Claude Code.</p><p>The MCP server (<a href="https://github.com/dogukannulu/text-to-sql/blob/main/mcp_server/server.py">mcp_server/server.py</a>) uses FastMCP to expose three tools to Claude Code:</p><pre>mcp = FastMCP(name=&quot;udogretail-text2sql&quot;)<br>@mcp.tool()<br>def query_tool(question: str, session_id: str = &quot;&quot;) -&gt; str:<br>&quot;&quot;&quot;Run a natural-language question through the Text-to-SQL agent.&quot;&quot;&quot;<br>…<br>@mcp.tool()<br>def schema_tool(keyword: str) -&gt; str:<br>&quot;&quot;&quot;Look up tables and columns matching a keyword.&quot;&quot;&quot;<br>…<br>@mcp.tool()<br>def history_tool(limit: int = 5) -&gt; str:<br>&quot;&quot;&quot;Fetch the last N query runs from agent history.&quot;&quot;&quot;<br>…</pre><p>The <a href="https://github.com/dogukannulu/text-to-sql/blob/main/.mcp.json">.mcp.json</a> at the repo root registers it as a stdio subprocess for Claude Code:</p><pre>{<br>&quot;mcpServers&quot;: {<br>  &quot;udogretail-text2sql&quot;: {<br>    &quot;type&quot;: &quot;stdio&quot;,<br>    &quot;command&quot;: &quot;.venv/bin/python&quot;,<br>    &quot;args&quot;: [&quot;mcp_server/server.py&quot;],<br>    &quot;env&quot;: {&quot;API_BASE_URL&quot;: &quot;http://localhost:8000&quot;}<br>    }<br>  }<br>}</pre><p>With the Docker stack running, I can ask Claude Code “how many active Pro subscribers are there?” and it calls query_tool directly, returning the SQL and result inline in the terminal.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w48th3JNvNPADBog6x6meQ.png" /></figure><p>One unexpected issue: a stdio MCP server exits immediately in Docker if stdin is closed. The fix is <strong>stdin_open: true</strong> and <strong>tty: true</strong> in the compose service definition. Without these, the container starts and exits with code 0 immediately.</p><h3>10. Results, lessons, and what I’d do differently</h3><p>The agent works. It handles simple count queries first-try, joins across three tables with appropriate GROUP BY, and self-corrects the most common errors — wrong column name, missing WHERE clause, incorrect JOIN direction. Langfuse traces confirm the self-correction loop fires roughly 20–30% of the time on harder questions. Clarification is reached rarely on well-formed questions.</p><h4>What I’d do differently:</h4><p><strong>More aggressive schema chunk verbosity:</strong> The chunks include column types and FK relationships, but not value distributions. Knowing that status has values (‘pending’, ‘confirmed’, ‘shipped’, ‘delivered’, ‘cancelled’, ‘refunded’) — hardcoded in the chunk — would have prevented several early errors where Claude guessed wrong enum values.</p><p><strong>Smarter retry routing:</strong> The current CORRECT node re-prompts from scratch. A better approach: if the error is “column does not exist”, retrieve fresh schema context before re-prompting. The schema is already there, but explicitly signalling what changed would improve correction rates.</p><p><strong>Evaluation before prompt tuning:</strong> I tuned the generate prompt by feel during development. Running the GEval harness earlier would have given me a quantitative signal — I could have measured the effect of each prompt change instead of reasoning about it. The changes for prompts into production should happen after seeing better GEval results.</p><h4>What surprised me:</h4><p>The column-name hallucination problem is real and consistent. Every time I tested a “top customers by spend” query before adding the CRITICAL: constraint, Claude generated first_name/last_name. After the constraint, it stopped. The fix was four lines of prompt text.</p><p>Decimal serialization is a silent failure. PostgreSQL NUMERIC columns return Python Decimal objects, which can’t be serialized to JSON. The error only surfaces at save time — the query succeeds, the results are correct, and then the API returns 500. Sanitising rows to float before saving is one of those fixes that should be in every SQL-over-API stack.</p><p>Langfuse SDK version pinning matters. SDK v4 dropped backward compatibility with Langfuse server v2. If you’re self-hosting, make sure your server and SDK versions are aligned. For anything new, use Langfuse Cloud — no version management required.</p><p>If you’re looking for freelance or consultancy work on data pipelines, AI systems, or real-time platforms, reach out to me on X, or via email.</p><p>And, if this saved you time or sparked an idea, consider sponsoring me on GitHub. Thank you!</p><p>GitHub: <a href="https://github.com/dogukannulu">https://github.com/dogukannulu</a></p><p>X: <a href="https://x.com/dogukannuluu">https://x.com/dogukannuluu</a></p><p>Email: dogukannulu@gmail.com</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=91a3d7beec49" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How Batch Pipelines Become Complex and Brittle Over Time]]></title>
            <link>https://medium.com/@dogukannulu/how-batch-pipelines-become-complex-and-brittle-over-time-1faf14589ff6?source=rss-717a86445022------2</link>
            <guid isPermaLink="false">https://medium.com/p/1faf14589ff6</guid>
            <category><![CDATA[estuary]]></category>
            <category><![CDATA[batch-processing]]></category>
            <category><![CDATA[data-pipeline]]></category>
            <dc:creator><![CDATA[Dogukan Ulu]]></dc:creator>
            <pubDate>Fri, 16 Jan 2026 11:12:42 GMT</pubDate>
            <atom:updated>2026-01-16T11:12:42.662Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UF-ivyexWNmzH2YMXumUwg.png" /></figure><h3>How Batch Pipelines Become Complex and Brittle Over Time</h3><p>Every team that works with batch pipelines has experienced a well-known nightmare: getting notified about a job failure in the middle of the night. When something like this happens, dashboards suddenly become stale, stakeholders start asking questions, and the first thing teams usually try to do is restart the job, assuming it was a temporary problem.</p><p>And sure, at the beginning of our evaluations, errors do look operational. But it becomes obvious that this is not the real underlying issue. The real problem is the architecture, which easily becomes fragile once the data volume and enterprise requirements increase.</p><p>But I didn’t write this article to list the bad behaviors of batch pipelines. After all, they are, without a doubt, the backbone of power analytical systems, so the question isn’t <em>why</em> do we use them? It’s <em>how</em> do we structure them to support real-time decisions, compliance guarantees, and reliable data at scale?</p><p>In this article, we’ll explore why batch pipelines start simple but begin failing as data volumes and requirements grow. We will also see how a unified data movement layer with Estuary can help you rewrite this story.</p><h3>Key Takeaways</h3><ul><li>As systems scale, batch pipelines often become fragile due to hidden state, dependency chains, and delayed data visibility.</li><li>Operational fixes and retries increase complexity over time instead of improving reliability.</li><li>A unified data movement layer handles incremental change continuously, reducing backfills, downtime, and data inconsistency.</li></ul><h3>Why “Just Run the Batch Job” Is Not Working Anymore</h3><p>As mentioned, most <a href="https://estuary.dev/blog/batch-processing-vs-stream-processing/">batch pipelines</a> usually look simple in the beginning. Batch jobs are generally scheduled overnight; this way, teams can address any malfunctions before the start of the next day.</p><p>Jobs consist of three major steps for <a href="https://estuary.dev/blog/what-is-an-etl-pipeline/">ETL or ELT pipelines</a>: extract, transform, load. The approach seems fine for small-scale jobs and companies. Stakeholders are limited, data is not huge yet, and fixes are relatively easy.</p><p>Though this sounds pretty simple, it’s just an illusion supported by entity-relationship diagrams.</p><p>The reality is more complex:</p><ul><li>Even though most components look to be stateless, you still have stateful offsets.</li><li>There are so many components that might break the order of data.</li><li>Every source system is prone to changes in the schema. While this is typically a matter of communication with the source team, overlooking it can impact all downstream components.</li></ul><p>When this happens, enterprises have high expectations. They want reliable and compliant data with clear ownership. It also needs to be so fresh that we can measure it in minutes.</p><p>Traditional batch architectures struggle to meet these demands.</p><h3>How Do Batch Pipelines Become Complex and Fragile?</h3><p>Problems in batch operations don’t usually appear out of nowhere. As setups get more complex, weaknesses start popping up slowly, making breakdowns more likely.</p><h3>The Hidden Statefulness of Batch Pipelines</h3><p>Even though we see batch jobs as stateless, there are plenty of states they are dependent on. A job may rely on successful completion of a previous run. In addition, source systems don’t promise anything regarding completeness, so we may get partial data with some job runs. We could also hit some rate limits on the source side, and each component might include internal watermarks.</p><p>Once these issues arise, teams typically try to find immediate workarounds to meet SLAs. But every workaround adds yet another state.</p><p>Here’s a very common pattern:</p><pre># You may see the pseudo-code reflecting the logic <br># behind an hourly Airflow task<br><br>last_cursor = read_from_metadata_store(&quot;api_cursor&quot;)<br><br>response = requests.get(<br>    API_ENDPOINT,<br>    params={&quot;cursor&quot;: last_cursor, &quot;limit&quot;: 1000}<br>)<br><br>write_to_s3(response.json())<br><br>new_cursor = response.json()[&quot;next_cursor&quot;]<br>write_to_metadata_store(&quot;api_cursor&quot;, new_cursor)</pre><p>Even though it looks like a stateless operation, every <a href="https://estuary.dev/blog/unified-data-layer/">Airflow task is prone</a> to retry once an issue occurs. The schemas might change in the source, or a temporary network issue may lead to partial results.</p><p>Estuary treats the state as a significant part of the flow. It’s not dependent on the orchestration metadata anymore. We can easily detect API changes on top of changelog tables:</p><pre>SELECT<br>    payload,<br>    cursor,<br>    captured_at<br>FROM api_capture_events<br>ORDER BY captured_at;</pre><h3>Dependency Chains and Cascading Failures</h3><p>As your data grows, so do your requirements. That’s why most teams start looking for better ways to handle it, which, in turn, increases internal dependencies between tasks.</p><p>When it comes to orchestration, it’s easy to view a job as a series of interdependent tasks. They all rely on one other, thanks to the trigger mechanism. Basically, once a task completes, it triggers the next one, and the chain reaction continues.</p><p>Take, for example, a batch pipeline that gets data from an API, writes the results into an S3 bucket, loads data into Snowflake, and creates analytical tables to be used in Looker.</p><p>The first step is running this Python script in EC2, which gets data from a sample API and writes the results to the S3 bucket:</p><pre>import json<br>import time<br>import requests<br>import boto3<br>from datetime import datetime<br><br>API_URL = &quot;https://api.example.com/v1/events&quot;<br>API_TOKEN = &quot;API_TOKEN&quot;<br>S3_BUCKET = &quot;raw-api-ingestion&quot;<br>S3_PREFIX = &quot;events&quot;<br><br>s3 = boto3.client(&quot;s3&quot;)<br><br>def fetch_events(page):<br>    response = requests.get(<br>        API_URL,<br>        headers={&quot;Authorization&quot;: f&quot;Bearer {API_TOKEN}&quot;},<br>        params={&quot;page&quot;: page, &quot;limit&quot;: 100}<br>    )<br>    response.raise_for_status()<br>    return response.json()<br><br>def write_to_s3(data, page):<br>    timestamp = datetime.utcnow()<br>    key = f&quot;{S3_PREFIX}/dt={timestamp}/events_page_{page}.json&quot;<br><br>    s3.put_object(<br>        Bucket=S3_BUCKET,<br>        Key=key,<br>        Body=json.dumps(data),<br>        ContentType=&quot;application/json&quot;<br>    )<br><br>def ingest_data():<br>    page = 1<br>    while True:<br>        payload = fetch_events(page)<br><br>        if not payload[&quot;data&quot;]:<br>            break<br><br>        write_to_s3(payload[&quot;data&quot;], page)<br><br>        page += 1<br>        time.sleep(0.5)<br><br>if __name__ == &quot;__main__&quot;:<br>    ingest_data()</pre><p>Next, we need to create an external stage in Snowflake, which mirrors the data that we have in the S3 bucket.</p><p>We also need to set up an hourly Snowflake task that copies the data from the stage into a raw table:</p><pre>COPY INTO snowflake_raw.api_events<br>FROM @api_stage<br>FILE_FORMAT = (TYPE = JSON);</pre><p>In the end, we need to run SQL queries to aggregate the raw data. This is going to create tables that will be used in the analytical layer in Looker:</p><pre>CREATE OR REPLACE TRANSIENT TABLE events_clean AS (<br>SELECT<br>    event_date,<br>    event_type,<br>    COUNT(*) AS total_events<br>FROM snowflake_analytics.events<br>GROUP BY event_date, event_type<br>);</pre><p>In this example, the first constraint is the API rate limits that might vary based on the time of the day. Partial responses might result in duplicates or missing data. The Snowflake COPY task can’t know whether the ingested data is complete or not, so Looker dashboards are updated with the wrong data without errors. As a result, dashboards may display missing events or duplicate entries.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/461/1*kFMAeDXKrmBGGGdq2vUZ0g.png" /></figure><p>When teams notice discrepancies, they need to pause Looker dashboards, re-run each step, and even backfill the entire data.</p><p>If these issues occur at any step, dashboards display the aggregated data late. Coordinating warehouse or database access across teams might be necessary, and entire database may even need to be restarted if there are no quick solutions. The main stakeholders should also be informed, regardless of whether they are internal or external users.</p><p>Estuary is capable of getting API data incrementally instead of getting partial API responses with batch pipelines. At the same time, you will keep the correct order and state. The whole process is quite straightforward. For example, let’s consider the HubSpot API, which is known for its complex structure.</p><p>On Estuary’s main page, click on Sources, search for HubSpot Incremental, and create a capture.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/898/1*4XRTTptkdaS3B9HnvlG-oA.png" /></figure><p>While defining it, you can mention all details about your HubSpot account, including your credentials. You can also specify which objects to sync, such as companies and deals. These will be the streams inside your HubSpot capture.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YVRXWzppw7ozh8Uoq_W2Xg.png" /></figure><p>Even though the HubSpot API is rate-limited, Estuary will throttle it and resume.</p><p>After the HubSpot capture is created, the destination in this particular case will be <a href="https://docs.estuary.dev/reference/Connectors/materialization-connectors/Snowflake/">Snowflake</a>. In the Estuary UI, you can refer to the Destinations section and choose Snowflake as the materialization.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/870/1*ShV1BQlez10hC5SebpgU5Q.png" /></figure><p>Once you’ve selected Snowflake as the destination, you should fill in the required fields with your Snowflake account information, including your credentials, the destination database, and the destination schema. You’ll also need to specify the role that is going to ingest the records and the warehouse that you will use during the ingestion.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SDLsp0QpVcD3KnRtyUENhQ.png" /></figure><p>Now you can create a binding between the newly created HubSpot capture and Snowflake materialization. You can edit your materialization and add a new binding. While defining it, you can choose the write mode, primary key, destination table, streams to ingest, schema evolution, and so on.</p><p>Once the whole sync is created, Estuary will perform an initial backfill and sync your source data to your destination with a right-time data approach.</p><p>As you can see, with Estuary, the data flow becomes much more solid:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/711/1*vPoNyKqoO6Eu5pSE1nsQyQ.png" /></figure><h3>Schema Changes and the Rising Cost of Batch Pipelines</h3><p>Any structure is prone to changes in the source system, including <a href="https://estuary.dev/blog/real-time-schema-evolution/">schema changes</a> or legacy columns. When this happens (and the communication with source team is lacking, which is often the case), the data we use might lose its meaning to stakeholders due to missing columns.</p><p>Batch pipelines struggle to adjust since they mostly rely on outdated data instead of steady methods. Once new changes are introduced to the pipeline, the result is often inaccurate data or failure in all components.</p><p>This isn’t about how teams implement the batch pipeline; it’s about the architecture in the background. Since it relies on initial conditions, any new changes can crash the working mechanism and cause failures.</p><h3>Practical Example: From Airflow DAGs to Right-Time Data</h3><p>When it comes to scheduling or orchestrating, the first tool that comes to your mind is probably <a href="https://airflow.apache.org/">Airflow</a>. Think about an hourly Airflow ELT DAG:</p><ol><li>It gets the data from any kind of source database, such as MySQL or PostgreSQL.</li><li>Then, it ingests the data into a warehouse for analytical purposes (think of BigQuery for this specific example).</li><li>After the data arrives in BigQuery, it aggregates the raw data located in the staging layer that was ingested in the previous step.</li><li>Finally, these analytical tables are used in visualization tools, like Looker.</li></ol><p>However, what seems like a suitable setup today might change tomorrow, so keep an eye out. Hourly jobs may easily interfere with one another, backfills often start right away, and rebooting jobs demand more processing power. In addition, job links might need to be tweaked by hand, and you may have to verify new data again.</p><p>This is what the DAG will look like:</p><pre>with DAG(<br>    dag_id=&quot;raw_orders_ingestion_and_transform&quot;,<br>    schedule_interval=&quot;@hourly&quot;,<br>    catchup=True,<br>) as dag:<br>    ingest_orders = MySqlOperator(<br>        task_id=&quot;ingest_orders&quot;,<br>        sql=&quot;&quot;&quot;<br>        SELECT *<br>        FROM orders<br>        WHERE updated_at &gt;= &#39;{{ data_interval_start }}&#39;<br>          AND updated_at &lt; &#39;{{ data_interval_end }}&#39;<br>        &quot;&quot;&quot;<br>    )<br>    transform_orders = BigQueryOperator(<br>        task_id=&quot;transform_orders&quot;,<br>        sql=&quot;&quot;&quot;<br>        CREATE OR REPLACE TABLE analytics.orders_clean AS<br>        SELECT<br>            order_id,<br>            user_id,<br>            amount,<br>            status<br>        FROM raw_orders<br>        WHERE created_at &gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)<br>        &quot;&quot;&quot;<br>    )<br>    compute_metrics = BigQueryOperator(<br>        task_id=&quot;compute_metrics&quot;,<br>        sql=&quot;&quot;&quot;<br>        INSERT INTO analytics.hourly_revenue<br>        SELECT<br>            TIMESTAMP_TRUNC(created_at, HOUR) AS hour,<br>            SUM(amount) AS revenue<br>        FROM analytics.orders_clean<br>        GROUP BY 1<br>        &quot;&quot;&quot;<br>    )<br>    <br>    ingest_orders &gt;&gt; transform_orders &gt;&gt; compute_metrics</pre><p>In this example, the ingest_orders task assumes that the source orders table includes all changes made over the last hour. It ingests the data into raw_orders in BigQuery. Since the transform_orders task assumes the ingest_orders task has completed successfully, late-arriving records are now a part of the transformation layer, and they are already in the orders_clean table. Because metrics are also calculated based on the results of transform_orders, dashboards may end up displaying inaccurate information.</p><p>In the case of a backfill, historical data, which downstream jobs have already consumed, is rewritten. As a result, if a retry happens in one task, it invalidates the results in another task. The important point is that none of these trigger an alert. They quietly decrease data quality instead. This causes longer chains of dependency, and when the first step breaks, everything downstream collapses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/901/1*Qxn9_w1VTfZBCC2LaQrjlw.png" /></figure><p>In Estuary, instead of long chains of dependencies, changes are detected only once and then, they start flowing continuously. For this specific example, the capture is going to be MySQL.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/858/1*W50sjgReLktuId-8dEbbNg.png" /></figure><p>While setting up your <a href="https://docs.estuary.dev/reference/Connectors/capture-connectors/MySQL/">MySQL</a> source, you should define all your credentials first. Then, you can configure many additional advanced settings, such as tables that you would not like to backfill, specific databases you would like to connect, and the backfill chunk size.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XRSG58hMbqAyupFoCh3MCw.png" /></figure><p>Once your source MySQL is ready, you can move on to creating the destination materialization. In this example, we are going to use <a href="https://docs.estuary.dev/reference/Connectors/materialization-connectors/BigQuery/">BigQuery</a> as the destination, and you may choose your materialization as BigQuery.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/872/1*PDLjOtZWSFDvTvwEUJ8VoQ.png" /></figure><p>Then, you should define the credentials for your BigQuery account. There are many available parameters you can configure, including the region where your BigQuery account is located, the dataset where you would like to see your ingested tables, and the Google Cloud Storage bucket that will be used for temporary data and specifications.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*B-nGDIghhV5Qj7gmUyhIGw.png" /></figure><p>With the additional advanced options, you can even specify more granular settings for your BigQuery materialization.</p><p>After creating both the source and the destination, you can establish a new binding between the MySQL source and the BigQuery destination. In the end, Estuary handles your data ingestion incrementally with the <a href="https://estuary.dev/right-time-data-manifesto/">right-time data approach</a> .</p><p>With this new implementation, you don’t need ingestion tasks in your Airflow DAGs. Since the data ingestion is going to be handled by Estuary, you can focus on locating your transformation tasks and keeping your Airflow DAGs more reliable.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/821/1*S_NBy1zS4fOK7AwaTZ8CJg.png" /></figure><p>This approach replaces the whole error-prone system with a steady flow of data.</p><p>With Estuary, you can sync your entire data set through <a href="https://estuary.dev/blog/log-based-cdc-vs-traditional-etl/">log-based CDC</a>, which will result in a steady pipeline. Even when something is updated in the source (schema change, column additions/deletions, etc.), Estuary is capable of detecting each change and automatically propagating them downstream.</p><p>The changes will have an immediate effect. Source and engineering teams can implement updates without relying on additional dependencies. Your data will remain fresh, up-to-date, and accurate, and you will not need full backfills or other operations that will create a costly load on your pipeline.</p><p>This is what real-time data actually looks like.</p><h3>The Hidden Cost of Batch-Heavy Architectures</h3><p>Batch pipelines are not designed to fail suddenly. Instead, they become insufficient as requirements grow.</p><h3>Compute Costs That Scale Non-Linearly</h3><p>If an issue happens in your batch pipeline and there is no quick workaround to resolve it, teams need to do backfills. This will come with a compute cost (warehouses in Snowflake, for example). Since data volume increases every day, each backfill will require extra compute cost over time.</p><h3>Engineering Required for Recovery</h3><p>One of primary goals of data engineering is to automate pipelines as much as possible. However, when an error occurs, human intervention is required.</p><p>The first thing we need to do is to detect the root cause and then re-run all the jobs if necessary. Afterward, we should perform sanity checks on downstream tables to ensure data quality. Finally, we need to communicate the issue and provide reliable updates to the stakeholders.</p><p>All of this requires significant time and effort from engineers.</p><h3>Compliance and Audit Risk</h3><p>Once a batch pipeline starts failing, it can lead to missing or partial data. Files or tables provided to stakeholders will lack important information, dashboards will start throwing errors, and daily/weekly reports may not work. Since failures are often partial, pipeline lineage can also become flawed.</p><p>All these issues create significant compliance and audit risks.</p><h3>Trust Loss</h3><p>We aim to build solid, reliable pipelines; however, just as importantly, we want to build trust with our users. In case of a critical failure, all downstream components will be broken. This will definitely impact the trust between engineers and end-users in a negative way, and more questions will arise regarding data accuracy.</p><p>Trust is built through consistency. Once an error happens or data loses its quality, this consistency is lost, and so is the confidence in the pipeline.</p><h3>Why Is a Unified Real-Time and Batch Approach Better?</h3><p>A unified approach treats data movement as a continuous flow rather than a series of scheduled jobs. The more frequently we check data freshness, the sooner stale data or any related issues can be detected. Instead of waiting for batches, real-time data flows enable smoother operations as a whole.</p><p>Modern AI or ML systems in particular are dependent on fresh and accurate data. By using <a href="https://estuary.dev/blog/the-complete-introduction-to-change-data-capture-cdc/">incremental check principles</a>, we can make sure our data is accurate and up-to-date. Since our models are trained on it, its stability is critical to prevent drifts. Real-time data is also essential if we want embeddings to remain current while agents are operating. At the same time, regulatory and audit requirements make clear lineage definitions essential.</p><p>Once pipelines are split into batch and streaming, data definitions become different, costs are duplicated, and complexity becomes hard to maintain.</p><p>A unified data movement layer solves all this.</p><h3>Continuous Sync and Lower Warehouse Costs</h3><p>Since Estuary syncs data incrementally, heavy computation costs are significantly reduced. Compute workers only process incremental changes rather than repeatedly scanning full tables, which results in more efficient warehouse usage.</p><h3>One Platform, One Model</h3><p><a href="https://estuary.dev/">Estuary</a> uses an approach that handles batch and real-time processing together. This means the same reliability promises, schema evolution guarantees, and governance model apply consistently across both systems. The architecture is simplified and operational maintenance is significantly reduced.</p><h3>Flexible Deployment for Enterprise Requirements</h3><p>Most pipelines are deployed into multiple cloud providers rather than relying on a single one. This is often done due to hybrid environment requirements or strict compliance rules.</p><p>Estuary’s <a href="https://estuary.dev/deployment-options/">BYOC and hybrid deployment models</a> address these enterprise needs without demanding any additional architectural changes to your system.</p><h3>Conclusion</h3><p>Estuary is more than just another pipeline tool. It creates a solid structure that makes data move smoothly across multiple systems, and it gives you the ability to leverage its power for analytics, AI/ML, and compliance.</p><p>Once batch pipelines start to become fragile due to the increasing data volumes, the issue is rarely about how teams operate or whether everything was implemented correctly. The root cause is the architecture itself, which was probably not designed to meet modern-day expectations.</p><p>The best solution lies in the unified data movement. It creates a fully reliable system where you can reduce costs significantly, minimize the risk of downtimes and errors, and rebuild trust with your stakeholders.</p><p>With Estuary, this transition is smooth and reliable.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1faf14589ff6" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>