AlpinaShop already has a complete data platform. And it has, precisely because of that, a new problem.
Every night this has to happen: export the day's orders from alpinashop-pedidos to Cloud Storage, load them into BigQuery, launch the Dataflow pipeline that cleanses and aggregates, run the aggregation queries, refresh the business view that feeds the dashboard, and — on the first day of every month — also launch the Spark job that recalculates the matrix of products bought together and the Data Fusion pipeline with the carrier's file.
That is eight processes with real dependencies between them. There is no sense in loading a file into BigQuery while it is still being written, nor in aggregating over half-loaded data, nor in refreshing the view with half the day's orders.
Right now that is handled by a cron on a virtual machine:
# /etc/crontab on the vm-procesos-nocturnos VM
0 2 * * * /opt/scripts/exportar_cloudsql.sh
30 2 * * * /opt/scripts/cargar_bigquery.sh
0 3 * * * /opt/scripts/lanzar_dataflow.sh
0 4 * * * /opt/scripts/agregaciones.sh
30 4 * * * /opt/scripts/refrescar_vista.shIt works. It works every night, until the first one where the export takes forty minutes instead of twenty because there was a campaign. Then, at 02:30, cargar_bigquery.sh reads an incomplete file. At 03:00, Dataflow processes partial data. At 04:30, the business view is refreshed with half the orders. At 09:00, management opens the dashboard and sees that yesterday's sales fell by 45%. An emergency meeting is called. At 11:30, Marta discovers that the figure is false.
Nobody found out about any of it because cron does not know whether anything worked. It only knows what time it is.
In this lesson you will see what a real orchestrator does, you will build the complete DAG for AlpinaShop's nightly process in Cloud Composer, you will learn its real cost — which is the decisive argument for a small business — and you will set up the lightweight alternative with Workflows and Cloud Scheduler, which is where AlpinaShop is going to start.
Contents
- Why
cronis not an orchestrator - What a real orchestrator does
- Cloud Composer: managed Apache Airflow
- Airflow concepts: DAG, task, operator, sensor
- The
alpinashop-composerenvironment and its cost - The nightly process DAG, line by line
- Google Cloud operators
- XComs, variables and connections
TaskGroup, dependencies and flow patterns- Idempotency,
catchupand reprocessing - Workflows: serverless orchestration in YAML
- Cloud Scheduler: the cron trigger
- The decision table and AlpinaShop's choice
- Why
cron is not an orchestrator
cron is not an orchestratorcron answers a single question: what time is it?. An orchestrator answers a very different one: what can be run now, given what has happened?.
| Situation | With cron |
With an orchestrator |
|---|---|---|
| Task A takes longer than expected | B starts anyway, with incomplete data | B waits for A to finish properly |
| Task A fails | B starts anyway, over nothing | B does not start; someone is notified |
| Transient network failure | The process dies | Automatic retry with a wait |
| Did it run last night? | Check logs over SSH | Dashboard with the full history |
| Reprocess 12 March | Manual script with hand-typed parameters | backfill of that date |
| Three independent tasks | In series, adding up the times | In parallel |
| Nobody finds out about a failure | Correct: nobody finds out | Alert configured |
The cron VM goes down |
Nothing runs and nobody knows | Managed service with retries |
| How long does each step take? | Nobody knows | Per-task metrics and history |
There is one especially insidious row: the cron VM. It is a machine somebody created three years ago, that nobody patches, with the scripts sitting in /opt with no version control, with credentials in .env files, and whose existence only two people remember. When that VM dies, it dies silently and the data platform stops updating for days.
The second insidious row is the guessed wait. The cron above assumes the export takes under 30 minutes. That margin is a bet, and bets are lost on precisely the highest-volume day, which is the day the data matters most.
- What a real orchestrator does
A workflow orchestrator brings five things:
Explicit dependencies. You declare that B depends on A, and the system guarantees the order. There are no times worked out by eye: whether A takes ten minutes or two hours, B starts when A finishes.
Failure handling. Retries with increasing waits, a maximum number of attempts, and what to do if it still fails: stop, carry on with whatever does not depend on it, or run a clean-up task.
Observability. A dashboard showing every run, every task, its duration, its logs and its history. Answering "did it work last night?" costs one glance.
Reproducibility. Being able to re-run the flow for a past date with that date's parameters, without editing anything.
Notification. Somebody finds out when something fails, and finds out in time.
Google Cloud offers three tools for this, and they are complementary:
| Tool | What it is | Model |
|---|---|---|
| Cloud Scheduler | A managed cron |
Fires an action at a given time |
| Workflows | Declarative serverless orchestration | Chains steps in YAML, with no server |
| Cloud Composer | Managed Apache Airflow | Full orchestration with Python |
- Cloud Composer: managed Apache Airflow
Apache Airflow is the de facto standard for data orchestration. It was born at Airbnb in 2014, it is open source, and its central idea is that workflows are defined as Python code.
That last point is its great virtue. A flow defined in Python is versioned in Git, reviewed in a pull request, tested, generated dynamically with loops, and admits all the logic of the language. Against a visual drag-the-boxes interface, a DAG in Python is infinitely more maintainable when the team can program.
Cloud Composer is Airflow managed by Google: the scheduler, the metadata database, the workers and the web interface running on GKE, with IAM, Cloud Logging and Cloud Monitoring integration, and with the Google Cloud operators already installed.
Current versions in 2026: Composer 3, with Airflow 2.x and 3.x. Composer 3 simplified the architecture considerably compared with Composer 2 — less visible infrastructure, finer scaling — but the cost model is essentially the same, and that is the point to look at before any other.
- Airflow concepts: DAG, task, operator, sensor
DAG (directed acyclic graph) is the complete workflow. Directed because the dependencies have a direction; acyclic because there can be no loops: if A depends on B and B on A, nothing could ever start.
Task is a node of the DAG: a unit of work.
Operator is the template that defines what a task does. Airflow ships hundreds: run Bash, call a Python function, launch a BigQuery query, create a Dataproc cluster, send an email.
Sensor is a special operator that waits for something to happen: for a file to appear in a bucket, for a table to have data, for an API to respond. It is the piece that solves the cron problem.
DAG run is a specific instance of the DAG for a given logical date.
flowchart LR
A["exportar_cloudsql<br/>BashOperator"]
B["esperar_fichero<br/>GCSObjectExistenceSensor"]
C["cargar_bigquery<br/>GCSToBigQueryOperator"]
D["lanzar_dataflow<br/>DataflowFlexTemplateOperator"]
E1["agregar_ventas<br/>BigQueryInsertJobOperator"]
E2["agregar_visitas<br/>BigQueryInsertJobOperator"]
F["refrescar_vista<br/>BigQueryInsertJobOperator"]
G["comprobar_calidad<br/>BigQueryCheckOperator"]
A --> B --> C --> D
D --> E1 --> F
D --> E2 --> F
F --> G
Notice that agregar_ventas and agregar_visitas run in parallel: both depend on lanzar_dataflow and neither depends on the other. Airflow works that out from the graph without being told. With cron you would have to decide an order and add up the times.
- The
alpinashop-composer environment and its cost
alpinashop-composer environment and its costgcloud config set project alpinashop-datos
gcloud services enable composer.googleapis.com
gcloud iam service-accounts create sa-composer \
--display-name="AlpinaShop Cloud Composer"
SA_COMP="[email protected]"
for ROLE in roles/composer.worker roles/bigquery.dataEditor roles/bigquery.jobUser \
roles/dataflow.developer roles/storage.objectAdmin \
roles/cloudsql.viewer roles/dataproc.editor; do
gcloud projects add-iam-policy-binding alpinashop-datos \
--member="serviceAccount:${SA_COMP}" --role="$ROLE"
done
gcloud composer environments create alpinashop-composer \
--location=europe-west1 \
--image-version=composer-3-airflow-2.10.5 \
--service-account="$SA_COMP" \
--network=alpinashop-vpc \
--subnetwork=sn-datos-euw1 \
--enable-private-environment \
--environment-size=small \
--labels=entorno=produccion,equipo=datos,centro-coste=analiticaCreation takes 20 to 30 minutes.
And now the uncomfortable conversation, which has to be had before writing a single line of DAG.
| Component | Approximate monthly cost (check in the official documentation) |
|---|---|
small environment (scheduler, web server, database) |
~€250-350 |
| Extra workers under load | Variable |
| Storage for the DAG bucket and logs | Cents |
| Realistic total for a small environment | ~€300-400/month |
Three hundred euros a month, whether you run one DAG or a hundred. It is a permanently switched-on service: the scheduler has to be alive to know what time it is.
Let us put that in context with the rest of AlpinaShop's platform:
| Service | Estimated monthly cost |
|---|---|
| BigQuery (storage + queries) | ~€10 |
| Cloud Storage (60 GB of catalogue) | ~€2 |
| Pub/Sub | Cents |
| Batch Dataflow (nightly) | ~€2 |
| Dataproc Serverless (monthly) | ~€1 |
| Cloud Composer | ~€350 |
The orchestrator would cost twenty times more than everything it orchestrates. That is not an argument against Composer: it is an argument against using it at the wrong moment. For a company with 200 DAGs, 50 engineers and cross-team dependencies, €350 is derisory against the value. For AlpinaShop, with eight nightly tasks, it is disproportionate.
We are going to build the complete DAG anyway, for three reasons: because Airflow is the industry standard and you have to know it; because the exercise of modelling the dependencies is valid for any orchestrator; and because the day AlpinaShop grows, this will be the tool. At the end of the lesson we will come back to the decision.
- The nightly process DAG, line by line
DAGs are deployed by copying them into the bucket Composer creates:
DAGS_BUCKET=$(gcloud composer environments describe alpinashop-composer \
--location=europe-west1 --format="value(config.dagGcsPrefix)")
gcloud storage cp dags/proceso_nocturno.py "${DAGS_BUCKET}/"And the DAG:
"""
proceso_nocturno.py -- AlpinaShop's nightly data process.
Flow:
export Cloud SQL -> wait for file -> load BigQuery -> Dataflow
-> aggregations (parallel) -> refresh view -> check quality
Deployment: copy to gs://<bucket-composer>/dags/
"""
from datetime import datetime, timedelta
import pendulum
from airflow import DAG
from airflow.operators.empty import EmptyOperator
from airflow.operators.python import PythonOperator
from airflow.providers.google.cloud.operators.bigquery import (
BigQueryCheckOperator,
BigQueryInsertJobOperator,
)
from airflow.providers.google.cloud.operators.cloud_sql import (
CloudSQLExportInstanceOperator,
)
from airflow.providers.google.cloud.operators.dataflow import (
DataflowStartFlexTemplateOperator,
)
from airflow.providers.google.cloud.sensors.gcs import GCSObjectExistenceSensor
from airflow.providers.google.cloud.transfers.gcs_to_bigquery import (
GCSToBigQueryOperator,
)
from airflow.utils.task_group import TaskGroup
DATA_PROJECT = "alpinashop-datos"
PROD_PROJECT = "alpinashop-prod"
DATASET = "alpinashop_analitica"
BUCKET = "alpinashop-datalake"
REGION = "europe-west1"
TIME_ZONE = pendulum.timezone("Europe/Madrid")# ---------------------------------------------------------------------------
# DEFAULT ARGUMENTS: they apply to EVERY task in the DAG.
# Defining them here avoids repeating them in each operator.
# ---------------------------------------------------------------------------
default_arguments = {
"owner": "equipo-datos",
"depends_on_past": False, # one run does not wait for the previous one
"email": ["[email protected]"],
"email_on_failure": True,
"email_on_retry": False,
"retries": 3, # 3 retries on failure
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True, # 5, 10, 20 min: do not hammer the source
"max_retry_delay": timedelta(minutes=30),
"execution_timeout": timedelta(hours=2), # no task runs forever
"sla": timedelta(hours=3), # warn if the DAG does not finish within 3 h
}Each of these parameters prevents a specific incident:
retrieswith exponential backoff: a transient network failure does not ruin the night, and the retries do not sink an already saturated system.execution_timeout: a hung task does not block the DAG indefinitely or tie up a worker forever.sla: if the process has not finished by 05:00, somebody finds out before management opens the report.depends_on_past: False: each night is independent. Set toTrue, a failure on Monday would block Tuesday, Wednesday and everything after, which is almost never what you want.
with DAG(
dag_id="alpinashop_proceso_nocturno",
description="Nightly process: Cloud SQL -> BigQuery -> Dataflow -> aggregates",
default_args=default_arguments,
# Every day at 02:00 Madrid time.
# Airflow works internally in UTC; the time zone avoids the one-hour
# drift at the daylight saving changeovers.
schedule="0 2 * * *",
start_date=datetime(2026, 3, 1, tzinfo=TIME_ZONE),
catchup=False, # see section 10
max_active_runs=1, # never two overlapping nights
tags=["alpinashop", "produccion", "datos"],
doc_md=__doc__, # the docstring is shown in the interface
) as dag:
start = EmptyOperator(task_id="inicio") # -----------------------------------------------------------------------
# 1) EXPORT Cloud SQL to Cloud Storage
#
# {{ ds }} is a Jinja template: Airflow replaces it with the run's
# logical date (yyyy-MM-dd). It is THE piece that makes the DAG
# reproducible: when reprocessing 12 March, {{ ds }} is 2026-03-12
# and the output file and the query point at that day, not at today.
# -----------------------------------------------------------------------
export_orders = CloudSQLExportInstanceOperator(
task_id="exportar_pedidos_cloudsql",
project_id=PROD_PROJECT,
instance="alpinashop-pedidos-replica-informes",
body={
"exportContext": {
"fileType": "CSV",
"uri": f"gs://{BUCKET}/exportaciones/{{{{ ds_nodash }}}}/pedidos.csv",
"databases": ["tienda"],
"csvExportOptions": {
"selectQuery": (
"SELECT pedido_id, creado_en, cliente_id, canal, estado, "
"pais, ciudad, codigo_postal, metodo_pago, "
"subtotal, descuento, iva, total "
"FROM pedidos "
"WHERE DATE(creado_en) = '{{ ds }}'"
)
},
}
},
)The export runs from the read replica, not from the primary instance. It is the same discipline as in 02-03 and 04-01: analytical processes do not touch the database that serves purchases.
# -----------------------------------------------------------------------
# 2) SENSOR: wait for the file to genuinely exist.
#
# This task is the one that solves the cron problem. The export is
# asynchronous: the previous operator launches it, but the file may
# take a while. Instead of "let us wait 30 minutes and cross our
# fingers", it is CHECKED.
# -----------------------------------------------------------------------
wait_for_file = GCSObjectExistenceSensor(
task_id="esperar_fichero_exportado",
bucket=BUCKET,
object="exportaciones/{{ ds_nodash }}/pedidos.csv",
# 'reschedule': frees the worker between checks instead of
# tying it up waiting. Essential for long waits.
mode="reschedule",
poke_interval=60, # check every minute
timeout=60 * 60, # give up after an hour
)The reschedule mode versus poke is a detail with real consequences: in poke mode, the sensor occupies a worker for the whole wait. With four sensors waiting an hour in a small environment, there is no free worker left and the DAG blocks itself. In reschedule mode, the sensor goes to sleep and gives the slot back.
# -----------------------------------------------------------------------
# 3) LOAD into BigQuery
# -----------------------------------------------------------------------
load_orders = GCSToBigQueryOperator(
task_id="cargar_pedidos_bigquery",
bucket=BUCKET,
source_objects=["exportaciones/{{ ds_nodash }}/pedidos.csv"],
destination_project_dataset_table=f"{DATA_PROJECT}.{DATASET}.pedidos_staging",
source_format="CSV",
skip_leading_rows=0,
field_delimiter=",",
null_marker="\\N",
# WRITE_TRUNCATE on the staging table: it is replaced every night.
# This makes the task IDEMPOTENT: re-running it duplicates nothing.
write_disposition="WRITE_TRUNCATE",
create_disposition="CREATE_IF_NEEDED",
autodetect=False,
schema_fields=[
{"name": "pedido_id", "type": "STRING", "mode": "REQUIRED"},
{"name": "creado_en", "type": "TIMESTAMP", "mode": "REQUIRED"},
{"name": "cliente_id", "type": "STRING"},
{"name": "canal", "type": "STRING"},
{"name": "estado", "type": "STRING"},
{"name": "pais", "type": "STRING"},
{"name": "ciudad", "type": "STRING"},
{"name": "codigo_postal", "type": "STRING"},
{"name": "metodo_pago", "type": "STRING"},
{"name": "subtotal", "type": "NUMERIC"},
{"name": "descuento", "type": "NUMERIC"},
{"name": "iva", "type": "NUMERIC"},
{"name": "total", "type": "NUMERIC"},
],
location=REGION,
)
# -----------------------------------------------------------------------
# 4) MERGE from staging into the final table: idempotent by design
# -----------------------------------------------------------------------
consolidate_orders = BigQueryInsertJobOperator(
task_id="consolidar_pedidos",
location=REGION,
configuration={
"query": {
"query": f"""
MERGE `{DATA_PROJECT}.{DATASET}.pedidos` AS destino
USING (
SELECT
pedido_id,
DATE(creado_en) AS fecha_pedido,
creado_en AS momento_pedido,
cliente_id, canal, estado,
STRUCT(pais, NULL AS provincia, ciudad, codigo_postal,
NULL AS metodo, CAST(NULL AS NUMERIC) AS coste) AS envio,
metodo_pago, NULL AS cupon,
subtotal, descuento, iva, total AS total_pedido
FROM `{DATA_PROJECT}.{DATASET}.pedidos_staging`
) AS origen
ON destino.pedido_id = origen.pedido_id
AND destino.fecha_pedido = origen.fecha_pedido
WHEN MATCHED THEN UPDATE SET
estado = origen.estado,
total_pedido = origen.total_pedido
WHEN NOT MATCHED THEN INSERT ROW
""",
"useLegacySql": False,
}
},
)The MERGE is the key to idempotency: if the task is re-run, existing orders are updated and new ones are inserted. Nothing is ever duplicated. It is exactly the same principle as Data Fusion's Upsert in 04-05 and the idempotency of the Pub/Sub consumers in 04-04. The same concept turns up in all four lessons because it is the property that lets a data system be operated without fear.
# -----------------------------------------------------------------------
# 5) DATAFLOW: flex template created in 04-02
# -----------------------------------------------------------------------
launch_dataflow = DataflowStartFlexTemplateOperator(
task_id="lanzar_pipeline_dataflow",
location=REGION,
project_id=DATA_PROJECT,
body={
"launchParameter": {
"jobName": "pedidos-lote-{{ ds_nodash }}",
"containerSpecGcsPath":
f"gs://alpinashop-dataflow/plantillas/pedidos-lote.json",
"parameters": {"fecha": "{{ ds }}"},
"environment": {
"serviceAccountEmail":
"[email protected]",
"subnetwork":
f"regions/{REGION}/subnetworks/sn-datos-euw1",
"ipConfiguration": "WORKER_IP_PRIVATE",
"maxWorkers": 10,
"additionalUserLabels": {
"entorno": "produccion", "equipo": "datos",
},
},
}
},
# The operator WAITS for the job to finish before marking the task
# as complete. Without this, the DAG would carry on with half-done data.
wait_until_finished=True,
)
# -----------------------------------------------------------------------
# 6) AGGREGATIONS IN PARALLEL, grouped so the graph stays readable
# -----------------------------------------------------------------------
with TaskGroup(group_id="agregaciones") as aggregation_group:
aggregate_sales = BigQueryInsertJobOperator(
task_id="ventas_por_categoria",
location=REGION,
configuration={
"query": {
"query": f"""
CREATE OR REPLACE TABLE
`{DATA_PROJECT}.{DATASET}.agg_ventas_categoria_dia`
PARTITION BY dia AS
SELECT
l.fecha_pedido AS dia,
pr.categoria,
COUNT(DISTINCT l.pedido_id) AS pedidos,
SUM(l.cantidad) AS unidades,
ROUND(SUM(l.importe_linea), 2) AS ventas_eur
FROM `{DATA_PROJECT}.{DATASET}.lineas_pedido` AS l
JOIN `{DATA_PROJECT}.{DATASET}.productos` AS pr
USING (sku)
WHERE l.fecha_pedido >= DATE_SUB(DATE '{{{{ ds }}}}',
INTERVAL 400 DAY)
GROUP BY dia, pr.categoria
""",
"useLegacySql": False,
}
},
)
aggregate_visits = BigQueryInsertJobOperator(
task_id="embudo_conversion",
location=REGION,
configuration={
"query": {
"query": f"""
CREATE OR REPLACE TABLE
`{DATA_PROJECT}.{DATASET}.agg_embudo_dia`
PARTITION BY dia AS
WITH marcas AS (
SELECT
fecha AS dia, sesion_id,
LOGICAL_OR(e.tipo = 'ver_producto') AS vio,
LOGICAL_OR(e.tipo = 'anadir_carrito') AS anadio,
LOGICAL_OR(e.tipo = 'iniciar_pago') AS pago,
LOGICAL_OR(e.tipo = 'compra') AS compro
FROM `{DATA_PROJECT}.{DATASET}.visitas`,
UNNEST(eventos) AS e
WHERE fecha BETWEEN DATE_SUB(DATE '{{{{ ds }}}}',
INTERVAL 90 DAY)
AND DATE '{{{{ ds }}}}'
GROUP BY dia, sesion_id
)
SELECT
dia,
COUNT(*) AS sesiones,
COUNTIF(vio) AS vieron_producto,
COUNTIF(anadio) AS anadieron_carrito,
COUNTIF(pago) AS iniciaron_pago,
COUNTIF(compro) AS compraron
FROM marcas
GROUP BY dia
""",
"useLegacySql": False,
}
},
)
# -----------------------------------------------------------------------
# 7) REFRESH the materialised view that feeds the dashboard
# -----------------------------------------------------------------------
refresh_view = BigQueryInsertJobOperator(
task_id="refrescar_vista_negocio",
location=REGION,
configuration={
"query": {
"query": f"""
CALL BQ.REFRESH_MATERIALIZED_VIEW(
'{DATA_PROJECT}.{DATASET}.mv_ventas_diarias_sku')
""",
"useLegacySql": False,
}
},
)
# -----------------------------------------------------------------------
# 8) QUALITY CHECK: the last line of defence before the report.
#
# If this task fails, the DAG is marked as failed and the alert
# fires. An alert at 05:00 is preferable to a management committee
# staring at false figures at 09:00.
# -----------------------------------------------------------------------
check_quality = BigQueryCheckOperator(
task_id="comprobar_calidad_datos",
location=REGION,
use_legacy_sql=False,
sql=f"""
SELECT
COUNTIF(pedidos_dia = 0) = 0 AND
COUNTIF(ventas_dia < 0) = 0 AND
COUNTIF(pedidos_dia > 500) = 0
FROM (
SELECT
dia,
SUM(pedidos) AS pedidos_dia,
SUM(ventas_eur) AS ventas_dia
FROM `{DATA_PROJECT}.{DATASET}.agg_ventas_categoria_dia`
WHERE dia = DATE '{{{{ ds }}}}'
GROUP BY dia
)
""",
)
def _notify_success(**context):
"""Informational callback: in production it would post to Slack or Chat."""
print(f"Nightly process completed for {context['ds']}")
end = PythonOperator(
task_id="fin",
python_callable=_notify_success,
trigger_rule="all_success",
)
# -----------------------------------------------------------------------
# DEPENDENCIES: the >> operator means "and then".
# This is the complete declaration of the graph, in four lines.
# -----------------------------------------------------------------------
(
start
>> export_orders
>> wait_for_file
>> load_orders
>> consolidate_orders
>> launch_dataflow
>> aggregation_group
>> refresh_view
>> check_quality
>> end
)Task 8, the quality check, is what turns this DAG into something serious. It verifies three business rules: that there were orders, that no sales figure is negative, and that no category exceeds 500 orders in a day (impossible at AlpinaShop's volume, so it would indicate duplication). If something does not add up, the DAG fails loudly before anybody looks at the report. It is the difference between catching an error at 05:00 and discovering it in a meeting.
- Google Cloud operators
Airflow ships an enormous catalogue of operators for Google Cloud. The most useful ones for AlpinaShop:
| Operator | What it does |
|---|---|
BigQueryInsertJobOperator |
Runs any BigQuery query or job. The most versatile |
BigQueryCheckOperator |
Runs SQL that must return true; if not, it fails |
BigQueryValueCheckOperator |
Compares a result against an expected value and a tolerance |
BigQueryTableExistenceSensor |
Waits for a table to exist |
GCSToBigQueryOperator |
Loads files from the bucket into a table |
BigQueryToGCSOperator |
Exports a table to files |
GCSObjectExistenceSensor |
Waits for an object to appear |
GCSToGCSOperator |
Copies or moves between buckets |
DataflowStartFlexTemplateOperator |
Launches a Dataflow flex template |
DataprocCreateBatchOperator |
Launches a job on Dataproc Serverless |
DataprocCreateClusterOperator / DeleteCluster |
Ephemeral cluster from the DAG |
CloudSQLExportInstanceOperator |
Exports a Cloud SQL instance |
PubSubPublishMessageOperator |
Publishes to a topic |
CloudRunExecuteJobOperator |
Runs a Cloud Run job |
For AlpinaShop's monthly process, the basket analysis from 04-03 is launched like this:
from airflow.providers.google.cloud.operators.dataproc import (
DataprocCreateBatchOperator,
)
basket_analysis = DataprocCreateBatchOperator(
task_id="analisis_cesta_mensual",
region=REGION,
project_id=DATA_PROJECT,
batch_id="cesta-{{ ds_nodash }}",
batch={
"pyspark_batch": {
"main_python_file_uri": f"gs://{BUCKET}/jobs/cesta_media.py",
"args": [
"--fecha-desde={{ macros.ds_add(ds, -30) }}",
"--fecha-hasta={{ ds }}",
],
},
"runtime_config": {"version": "2.2"},
"environment_config": {
"execution_config": {
"service_account":
"[email protected]",
"subnetwork_uri": "sn-datos-euw1",
}
},
},
){{ macros.ds_add(ds, -30) }} calculates a date relative to the run date. That is what keeps the DAG reproducible: when reprocessing January, the range will be January's, not today's.
- XComs, variables and connections
XCom (cross-communication) lets one task pass a small value to another:
def _count_rows(**context):
from airflow.providers.google.cloud.hooks.bigquery import BigQueryHook
hook = BigQueryHook(location=REGION, use_legacy_sql=False)
rows = hook.get_first(
f"""SELECT COUNT(*) FROM `{DATA_PROJECT}.{DATASET}.pedidos_staging`"""
)[0]
# Whatever the function returns is stored automatically as an XCom
return int(rows)
def _validate_volume(**context):
rows = context["ti"].xcom_pull(task_ids="contar_filas_cargadas")
if rows == 0:
raise ValueError(f"No orders loaded for {context['ds']}")
if rows > 5000:
raise ValueError(f"{rows} orders: anomalous volume, check for duplicates")
print(f"Volume looks right: {rows} orders")
count_rows = PythonOperator(task_id="contar_filas_cargadas",
python_callable=_count_rows)
validate_volume = PythonOperator(task_id="validar_volumen",
python_callable=_validate_volume)XComs are for small values: identifiers, counters, paths. They are stored in Airflow's metadata database. Passing a DataFrame through an XCom is a classic mistake that bloats the database and degrades the whole environment. For large data, you write to Cloud Storage and pass the path.
Variables are global configuration, editable from the interface without touching code:
from airflow.models import Variable
threshold = int(Variable.get("alpinashop_umbral_pedidos_dia", default_var="500"))gcloud composer environments run alpinashop-composer \
--location=europe-west1 variables -- set alpinashop_umbral_pedidos_dia 500Connections store credentials and access parameters for external systems, encrypted. For anything genuinely sensitive, the right approach is for the connection to point at Secret Manager (03-06) through Airflow's secrets backend, instead of keeping the password in the metadata database.
TaskGroup, dependencies and flow patterns
TaskGroup, dependencies and flow patternsThe dependency operators:
a >> b # b after a
a << b # a after b
a >> [b, c] >> d # b and c in parallel, both after a; d after both
[a, b] >> c # c waits for a and b to finishThe trigger rules (trigger_rule) control under what condition a task runs:
| Rule | It runs when |
|---|---|
all_success (default) |
Every upstream task succeeded |
all_failed |
They all failed |
all_done |
They all finished, successfully or not |
one_success |
At least one succeeded |
one_failed |
At least one failed |
none_failed_min_one_success |
None failed and at least one ran |
The all_done rule is essential for clean-up tasks:
from airflow.providers.google.cloud.operators.dataproc import (
DataprocDeleteClusterOperator,
)
delete_cluster = DataprocDeleteClusterOperator(
task_id="borrar_cluster",
cluster_name="cluster-efimero-{{ ds_nodash }}",
region=REGION,
project_id=DATA_PROJECT,
# CRITICAL: the cluster is deleted WHATEVER HAPPENS.
# With all_success, a job failure would leave the cluster switched on
# and billing indefinitely.
trigger_rule="all_done",
)And one_failed for error notifications:
notify_failure = PythonOperator(
task_id="avisar_fallo",
python_callable=_notify_slack,
trigger_rule="one_failed",
)
[load_orders, launch_dataflow, refresh_view] >> notify_failureTaskGroups visually group related tasks, collapsing them into a single node in the interface. With a thirty-task DAG, it is the difference between a readable graph and a tangle.
- Idempotency,
catchup and reprocessing
catchup and reprocessingThe logical date. Airflow runs each DAG for a logical date, available as {{ ds }}. That is what makes the DAG reproducible: when reprocessing 12 March, every template resolves to 2026-03-12.
The golden rule: a task must never use CURRENT_DATE() or datetime.now(). It must use {{ ds }}. With CURRENT_DATE(), reprocessing 12 March would recalculate using today's date and silently produce a wrong result.
# WRONG: not reproducible
"query": "SELECT ... WHERE fecha = CURRENT_DATE()"
# RIGHT: reproducible
"query": "SELECT ... WHERE fecha = DATE '{{ ds }}'"catchup. If you set catchup=True and the start_date is three months ago, Airflow will run all the outstanding dates when the DAG is enabled. That can be useful for filling in a history, or it can launch ninety simultaneous runs and exhaust the BigQuery quota. For AlpinaShop: catchup=False, and backfills are done by hand and under control.
backfill. Reprocessing a range explicitly:
gcloud composer environments run alpinashop-composer \
--location=europe-west1 dags backfill -- \
--start-date 2026-03-10 --end-date 2026-03-15 \
--reset-dagruns \
alpinashop_proceso_nocturnoThat command is only safe if every task is idempotent. That is why the DAG uses WRITE_TRUNCATE in staging, MERGE in the consolidation and CREATE OR REPLACE TABLE in the aggregations. A DAG with INSERT instead of MERGE would duplicate data on every reprocessing, and the reprocessing — which ought to be the repair tool — would become the cause of a worse problem.
Re-running a single task, without the whole DAG:
gcloud composer environments run alpinashop-composer \
--location=europe-west1 tasks clear -- \
--task-regex "refrescar_vista_negocio" \
--start-date 2026-03-14 --end-date 2026-03-14 --yes \
alpinashop_proceso_nocturno
- Workflows: serverless orchestration in YAML
If Composer costs €350 a month and AlpinaShop has eight tasks, there is an alternative: Cloud Workflows, declarative orchestration with no server and no fixed cost. You pay per step executed, and that is cents.
A flow is defined in YAML and runs when it is invoked:
# proceso-nocturno.yaml -- AlpinaShop's nightly process with Workflows
main:
params: [entrada]
steps:
- inicializar:
assign:
- proyecto: "alpinashop-datos"
- proyecto_prod: "alpinashop-prod"
- region: "europe-west1"
- dataset: "alpinashop_analitica"
- bucket: "alpinashop-datalake"
# If no date is passed, yesterday is used. This allows reprocessing
# by invoking the flow with {"fecha": "2026-03-12"}.
- fecha: ${default(map.get(entrada, "fecha"), text.substring(time.format(sys.now() - 86400), 0, 10))}
- fecha_compacta: ${text.replace_all(fecha, "-", "")}
# -----------------------------------------------------------------
# 1) Export Cloud SQL. The API returns a LONG-RUNNING operation:
# you have to wait for it, not assume it is done.
# -----------------------------------------------------------------
- exportar_cloudsql:
call: googleapis.sqladmin.v1.instances.export
args:
project: ${proyecto_prod}
instance: "alpinashop-pedidos-replica-informes"
body:
exportContext:
fileType: "CSV"
uri: ${"gs://" + bucket + "/exportaciones/" + fecha_compacta + "/pedidos.csv"}
databases: ["tienda"]
csvExportOptions:
selectQuery: ${"SELECT pedido_id, creado_en, cliente_id, canal, estado, pais, ciudad, codigo_postal, metodo_pago, subtotal, descuento, iva, total FROM pedidos WHERE DATE(creado_en) = '" + fecha + "'"}
result: operacion_export
- esperar_export:
call: sys.sleep
args:
seconds: 30
# -----------------------------------------------------------------
# 2) Check that the file exists. The equivalent of the sensor.
# -----------------------------------------------------------------
- comprobar_fichero:
try:
call: googleapis.storage.v1.objects.get
args:
bucket: ${bucket}
object: ${"exportaciones%2F" + fecha_compacta + "%2Fpedidos.csv"}
result: info_fichero
retry:
predicate: ${http.default_retry_predicate}
max_retries: 20
backoff:
initial_delay: 30
max_delay: 120
multiplier: 1.5
# -----------------------------------------------------------------
# 3) Load into BigQuery
# -----------------------------------------------------------------
- cargar_bigquery:
call: googleapis.bigquery.v2.jobs.insert
args:
projectId: ${proyecto}
body:
configuration:
load:
sourceUris:
- ${"gs://" + bucket + "/exportaciones/" + fecha_compacta + "/pedidos.csv"}
destinationTable:
projectId: ${proyecto}
datasetId: ${dataset}
tableId: "pedidos_staging"
sourceFormat: "CSV"
writeDisposition: "WRITE_TRUNCATE"
autodetect: true
result: job_carga
- esperar_carga:
call: espera_job_bigquery
args:
proyecto: ${proyecto}
job_id: ${job_carga.jobReference.jobId}
result: estado_carga
# -----------------------------------------------------------------
# 4) Launch Dataflow with the flex template
# -----------------------------------------------------------------
- lanzar_dataflow:
call: http.post
args:
url: ${"https://dataflow.googleapis.com/v1b3/projects/" + proyecto + "/locations/" + region + "/flexTemplates:launch"}
auth:
type: OAuth2
body:
launchParameter:
jobName: ${"pedidos-lote-" + fecha_compacta}
containerSpecGcsPath: "gs://alpinashop-dataflow/plantillas/pedidos-lote.json"
parameters:
fecha: ${fecha}
environment:
serviceAccountEmail: "[email protected]"
subnetwork: ${"regions/" + region + "/subnetworks/sn-datos-euw1"}
ipConfiguration: "WORKER_IP_PRIVATE"
result: job_dataflow
# -----------------------------------------------------------------
# 5) Aggregations IN PARALLEL with the 'parallel' branch
# -----------------------------------------------------------------
- agregaciones:
parallel:
branches:
- ventas:
steps:
- consulta_ventas:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"CREATE OR REPLACE TABLE `" + proyecto + "." + dataset + ".agg_ventas_categoria_dia` PARTITION BY dia AS SELECT l.fecha_pedido AS dia, pr.categoria, COUNT(DISTINCT l.pedido_id) AS pedidos, SUM(l.cantidad) AS unidades, ROUND(SUM(l.importe_linea),2) AS ventas_eur FROM `" + proyecto + "." + dataset + ".lineas_pedido` l JOIN `" + proyecto + "." + dataset + ".productos` pr USING (sku) WHERE l.fecha_pedido >= DATE_SUB(DATE '" + fecha + "', INTERVAL 400 DAY) GROUP BY dia, pr.categoria"}
- embudo:
steps:
- consulta_embudo:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"CREATE OR REPLACE TABLE `" + proyecto + "." + dataset + ".agg_embudo_dia` PARTITION BY dia AS SELECT fecha AS dia, COUNT(DISTINCT sesion_id) AS sesiones FROM `" + proyecto + "." + dataset + ".visitas` WHERE fecha = DATE '" + fecha + "' GROUP BY dia"}
# -----------------------------------------------------------------
# 6) Quality check: if it fails, an explicit error is raised
# -----------------------------------------------------------------
- comprobar_calidad:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"SELECT COUNT(*) AS n FROM `" + proyecto + "." + dataset + ".agg_ventas_categoria_dia` WHERE dia = DATE '" + fecha + "'"}
result: resultado_calidad
- evaluar_calidad:
switch:
- condition: ${int(resultado_calidad.rows[0].f[0].v) == 0}
raise: ${"QUALITY: no aggregated sales for " + fecha}
- devolver:
return:
fecha: ${fecha}
estado: "OK"
job_dataflow: ${job_dataflow.body.job.id}
# ---------------------------------------------------------------------
# Reusable SUBWORKFLOWS
# ---------------------------------------------------------------------
ejecuta_sql:
params: [proyecto, sql]
steps:
- lanzar:
call: googleapis.bigquery.v2.jobs.query
args:
projectId: ${proyecto}
body:
query: ${sql}
useLegacySql: false
location: "europe-west1"
timeoutMs: 300000
result: r
- devolver:
return: ${r}
espera_job_bigquery:
params: [proyecto, job_id]
steps:
- consultar:
call: googleapis.bigquery.v2.jobs.get
args:
projectId: ${proyecto}
jobId: ${job_id}
result: estado
- evaluar:
switch:
- condition: ${estado.status.state == "DONE"}
next: comprobar_error
next: esperar
- esperar:
call: sys.sleep
args:
seconds: 15
next: consultar
- comprobar_error:
switch:
- condition: ${"errorResult" in estado.status}
raise: ${estado.status.errorResult.message}
- devolver:
return: ${estado}Deployment and execution:
gcloud iam service-accounts create sa-workflows-nocturno \
--display-name="Nightly orchestration with Workflows"
SA_WF="[email protected]"
for ROLE in roles/bigquery.dataEditor roles/bigquery.jobUser \
roles/dataflow.developer roles/storage.objectAdmin \
roles/cloudsql.editor roles/logging.logWriter; do
gcloud projects add-iam-policy-binding alpinashop-datos \
--member="serviceAccount:${SA_WF}" --role="$ROLE"
done
gcloud workflows deploy alpinashop-proceso-nocturno \
--source=proceso-nocturno.yaml \
--location=europe-west1 \
--service-account="$SA_WF" \
--labels=entorno=produccion,equipo=datos,centro-coste=analitica
# Manual run with an explicit date (reprocessing)
gcloud workflows run alpinashop-proceso-nocturno \
--location=europe-west1 \
--data='{"fecha":"2026-03-12"}'
# See the result
gcloud workflows executions list alpinashop-proceso-nocturno \
--location=europe-west1 --limit=5 \
--format="table(name.basename(), state, startTime, endTime)"Workflows' honest limits, which you need to know before committing:
| Limit | Approximate value | Implication |
|---|---|---|
| Maximum duration of a run | 1 year | No problem |
| Maximum duration of an HTTP call | 30 min | A long job must be polled, not waited on |
| Maximum size of a variable | 512 KB | Do not pass data, only references |
| Steps per run | ~100,000 | Plenty |
| Visual interface | Simple graph | Far inferior to Airflow's dashboard |
backfill of a range |
Does not exist | You have to invoke it in a loop from a script |
| Event sensors | Manual polling with retry |
Less elegant than an Airflow sensor |
| Operator ecosystem | API calls | None of Airflow's hundreds of operators |
The three rows that weigh most are the non-existent backfill, the poorer observability and the absence of ready-made operators. With eight tasks that is bearable; with eighty, painful.
- Cloud Scheduler: the cron trigger
Workflows has no scheduler of its own: it has to be triggered. That is what Cloud Scheduler does, a managed cron that costs practically nothing (the first jobs are free and after that it is cents).
gcloud iam service-accounts create sa-scheduler-nocturno \
--display-name="Nightly process trigger"
SA_SCH="[email protected]"
gcloud projects add-iam-policy-binding alpinashop-datos \
--member="serviceAccount:${SA_SCH}" --role="roles/workflows.invoker"
gcloud scheduler jobs create http disparar-proceso-nocturno \
--location=europe-west1 \
--schedule="0 2 * * *" \
--time-zone="Europe/Madrid" \
--uri="https://workflowexecutions.googleapis.com/v1/projects/alpinashop-datos/locations/europe-west1/workflows/alpinashop-proceso-nocturno/executions" \
--http-method=POST \
--oauth-service-account-email="$SA_SCH" \
--message-body='{"argument":"{}"}' \
--max-retry-attempts=3 \
--min-backoff=60s \
--max-backoff=600s \
--attempt-deadline=60s--time-zone="Europe/Madrid" matters: the job runs at 02:00 local time, and Google handles the daylight saving changeovers. With UTC, the process would shift by an hour twice a year, which sounds harmless until it coincides with another system's maintenance window.
Cloud Scheduler can also publish to Pub/Sub or invoke Cloud Run directly, which makes it the platform's universal trigger.
And with the bucket notification from 04-04, you can set up a reactive flow instead of a scheduled one:
flowchart LR
S["Cloud Scheduler<br/>02:00 Europe/Madrid"]
W["Workflows<br/>nightly process"]
G["Cloud Storage<br/>carrier file"]
P["Pub/Sub<br/>imagenes-subidas"]
F["Cloud Function<br/>06-03"]
S -->|cron| W
G -->|notification| P --> F -->|invokes| W
The flow fires at 02:00 or when a file arrives, whichever happens. It is more robust than a fixed time, because it does not depend on the supplier being punctual.
- The decision table and AlpinaShop's choice
| Criterion | Cloud Scheduler | Workflows | Cloud Composer | Dataflow templates |
|---|---|---|---|---|
| What it solves | "Run this at this time" | "Run these steps in this order" | Full orchestration | One specific pipeline |
| Definition | Cron + target | Declarative YAML | Python | Parameters |
| Fixed cost | ~€0 | €0 | ~€350/month | €0 |
| Variable cost | Cents | Cents per step | Workers | The job's resources |
| Complex dependencies | No | Yes, within limits | Yes, without limits | No |
| Parallelism | No | Yes (parallel) |
Yes | Internal |
| Retries | Yes | Yes, configurable | Yes, very fine-grained | Yes |
| Event sensors | No | Manual polling | Yes, native | No |
backfill |
No | Manual | Yes, native | No |
| Observability | Logs | Simple graph | Full dashboard | Dataflow interface |
| Ecosystem | N/A | Google APIs | Hundreds of operators | N/A |
| Learning curve | Minutes | Hours | Days | Minutes |
| Choose it if… | One recurring action | A few dozen steps | Dozens of DAGs and teams | A standalone pipeline |
AlpinaShop's decision: start with Workflows + Cloud Scheduler.
The reasoning, which is what you have to be able to defend:
- Cost rules. €350 a month to orchestrate eight tasks is disproportionate when the whole data platform costs €15. With Workflows and Scheduler, the orchestration costs cents.
- The current complexity does not justify it. Eight tasks with linear dependencies and one parallel fork fit comfortably into 150 lines of YAML. Airflow shines with fifty DAGs sharing dependencies across teams, and AlpinaShop has one.
- There is no relevant loss of function. Dependencies, parallelism, retries with backoff, quality checking and alerts are all covered. What is missing — native
backfill, sensors, a rich dashboard — is made up for with a loop invocation script and Cloud Monitoring alerts. - Migrating later is viable. If twenty DAGs are needed tomorrow, the Composer environment is created and things are migrated. The logic of the tasks — SQL queries, Dataflow templates, Dataproc jobs — does not change: only who invokes them changes. Nothing already built gets thrown away.
The objective criteria for jumping to Composer, written down in advance so it is not argued in the heat of the moment:
- More than 10 distinct flows with dependencies between them.
- A recurring need for
backfillover long ranges. - More than one person maintaining the flows, with pull request review.
- A need for specialised operators (Salesforce, SAP, Kubernetes).
- The orchestrator's cost dropping below 10% of the cost of the platform it orchestrates.
That last criterion is the most useful and the easiest to check: when AlpinaShop's data platform costs €3,500 a month, Composer will cost 10% and will be justified. Today it would cost 2,300%.
Common Mistakes and Tips
Using CURRENT_DATE() instead of {{ ds }}. It silently breaks reproducibility: reprocessing a past date calculates with today's and nobody notices.
Leaving catchup=True by accident. Enabling a DAG with an old start_date launches hundreds of runs at once, exhausts the quotas and sends the cost through the roof.
Sensors in poke mode with long waits. They occupy workers. With several simultaneous sensors, the environment blocks itself. Use mode="reschedule".
Non-idempotent tasks. An INSERT instead of a MERGE turns every retry and every reprocessing into a data duplication. Idempotency is not optional in a DAG.
Passing large data through XCom. It is stored in the metadata database and degrades it. Pass paths, not contents.
Forgetting trigger_rule="all_done" on clean-up tasks. An ephemeral cluster that does not get deleted because the job failed stays switched on and billing indefinitely.
Not setting execution_timeout. A hung task holds a worker forever and ends up blocking the whole DAG.
Putting heavy logic in the body of the DAG. The file is re-evaluated every few seconds by the scheduler. A database query outside an operator runs constantly and sinks the environment. All the work goes inside operators.
Leaving Composer switched on "just in case". It is the equivalent of the permanent Dataproc cluster in 04-03 and the permanent Data Fusion in 04-05: the same mistake, three times over, and always the most expensive line on the bill.
Tip: keep the DAGs in Git and deploy with CI. The DAG bucket is not where the code lives, it is where it is copied to. In 06-01 we will automate that with Cloud Build.
Tip: put a quality check at the end of every flow. It is the task that turns an automated process into a reliable one. Catching the error at 05:00 is worth far more than discovering it in a committee meeting.
Tip: name the tasks with verbs. exportar_pedidos_cloudsql, not tarea_1. When something fails at 03:00, the name is the first thing anyone reads.
Exercises
Exercise 1: monthly basket analysis DAG
Write an Airflow DAG called alpinashop_analisis_mensual that runs on the first day of each month at 04:00 Madrid time and performs: (1) check that the lineas_pedido table has data from the previous month, failing if not; (2) launch the Dataproc Serverless job cesta_media.py with the previous month's dates as arguments; (3) in parallel, run two BigQuery queries that calculate the margin per category and the products with no sales that month; (4) refresh the materialised view; (5) publish a message to an informes-listos topic stating that the monthly report is available. Include retries, an SLA and failure notification.
Exercise 2: the same process in Workflows
Implement in YAML a flow alpinashop-informe-mensual equivalent to steps 1, 2 and 3 of the previous exercise, which accepts a mes parameter in yyyy-MM format and uses the previous month if it is not passed. It must run the two queries in parallel, wait correctly for the Dataproc job to finish (it is a long-running operation) and raise an explicit error if the initial check finds no data. Add the Cloud Scheduler job that triggers it.
Exercise 3: diagnosing a DAG that lies
For three weeks, the alpinashop_proceso_nocturno DAG shows green every night. But on Monday, Lucía spots that the agg_ventas_categoria_dia table has 24 February's data repeated in every partition since that date. Investigating, you find: the consolidar_pedidos task uses INSERT INTO instead of MERGE; the aggregation query filters on WHERE l.fecha_pedido = CURRENT_DATE() - 1; the comprobar_calidad_datos task only verifies that the table is not empty; and on 24 February somebody ran a backfill of the previous two weeks.
Explain exactly what has happened and in what order, why the DAG showed green, and propose the concrete fixes — with the code — for each of the four problems.
Solutions
Solution 1
"""alpinashop_analisis_mensual.py -- Monthly basket and margin analysis."""
from datetime import datetime, timedelta
import pendulum
from airflow import DAG
from airflow.providers.google.cloud.operators.bigquery import (
BigQueryCheckOperator, BigQueryInsertJobOperator,
)
from airflow.providers.google.cloud.operators.dataproc import (
DataprocCreateBatchOperator,
)
from airflow.providers.google.cloud.operators.pubsub import (
PubSubPublishMessageOperator,
)
from airflow.utils.task_group import TaskGroup
PROJECT = "alpinashop-datos"
DATASET = "alpinashop_analitica"
BUCKET = "alpinashop-datalake"
REGION = "europe-west1"
TZ = pendulum.timezone("Europe/Madrid")
arguments = {
"owner": "equipo-datos",
"email": ["[email protected]"],
"email_on_failure": True,
"retries": 2,
"retry_delay": timedelta(minutes=10),
"retry_exponential_backoff": True,
"execution_timeout": timedelta(hours=3),
"sla": timedelta(hours=4),
}
with DAG(
dag_id="alpinashop_analisis_mensual",
default_args=arguments,
schedule="0 4 1 * *", # 1st of each month at 04:00
start_date=datetime(2026, 3, 1, tzinfo=TZ),
catchup=False,
max_active_runs=1,
tags=["alpinashop", "mensual", "datos"],
) as dag:
# ds of the 1st -> the previous month runs from its 1st to its last day
FIRST_DAY_PREV_MONTH = "{{ macros.ds_format(macros.ds_add(ds, -1), '%Y-%m-%d', '%Y-%m-01') }}"
LAST_DAY_PREV_MONTH = "{{ macros.ds_add(ds, -1) }}"
# 1) Check that there is data from the previous month
check_data = BigQueryCheckOperator(
task_id="comprobar_datos_mes_anterior",
location=REGION,
use_legacy_sql=False,
sql=f"""
SELECT COUNT(*) > 0
FROM `{PROJECT}.{DATASET}.lineas_pedido`
WHERE fecha_pedido BETWEEN DATE '{FIRST_DAY_PREV_MONTH}'
AND DATE '{LAST_DAY_PREV_MONTH}'
""",
)
# 2) Basket analysis on Dataproc Serverless
basket_analysis = DataprocCreateBatchOperator(
task_id="analisis_cesta",
region=REGION,
project_id=PROJECT,
batch_id="cesta-{{ ds_nodash }}",
batch={
"pyspark_batch": {
"main_python_file_uri": f"gs://{BUCKET}/jobs/cesta_media.py",
"args": [
f"--fecha-desde={FIRST_DAY_PREV_MONTH}",
f"--fecha-hasta={LAST_DAY_PREV_MONTH}",
],
},
"runtime_config": {"version": "2.2"},
"environment_config": {
"execution_config": {
"service_account":
"[email protected]",
"subnetwork_uri": "sn-datos-euw1",
}
},
},
)
# 3) Two queries in parallel
with TaskGroup(group_id="informes_mensuales") as reports:
margin_by_category = BigQueryInsertJobOperator(
task_id="margen_por_categoria",
location=REGION,
configuration={"query": {"useLegacySql": False, "query": f"""
CREATE OR REPLACE TABLE `{PROJECT}.{DATASET}.agg_margen_mes` AS
SELECT
DATE '{FIRST_DAY_PREV_MONTH}' AS mes,
pr.categoria,
SUM(l.cantidad) AS unidades,
ROUND(SUM(l.importe_linea), 2) AS ventas_eur,
ROUND(SUM(l.cantidad * c.coste_medio_eur), 2) AS coste_eur,
ROUND(SUM(l.importe_linea)
- SUM(l.cantidad * c.coste_medio_eur), 2) AS margen_eur
FROM `{PROJECT}.{DATASET}.lineas_pedido` AS l
JOIN `{PROJECT}.{DATASET}.productos` AS pr USING (sku)
JOIN `{PROJECT}.{DATASET}.costes_producto_erp` AS c USING (sku)
WHERE l.fecha_pedido BETWEEN DATE '{FIRST_DAY_PREV_MONTH}'
AND DATE '{LAST_DAY_PREV_MONTH}'
GROUP BY mes, pr.categoria
"""}},
)
no_sales = BigQueryInsertJobOperator(
task_id="productos_sin_ventas",
location=REGION,
configuration={"query": {"useLegacySql": False, "query": f"""
CREATE OR REPLACE TABLE `{PROJECT}.{DATASET}.agg_sin_ventas_mes` AS
SELECT
DATE '{FIRST_DAY_PREV_MONTH}' AS mes,
pr.sku, pr.nombre, pr.categoria, pr.precio_catalogo
FROM `{PROJECT}.{DATASET}.productos` AS pr
WHERE pr.activo = TRUE
AND NOT EXISTS (
SELECT 1 FROM `{PROJECT}.{DATASET}.lineas_pedido` AS l
WHERE l.sku = pr.sku
AND l.fecha_pedido BETWEEN DATE '{FIRST_DAY_PREV_MONTH}'
AND DATE '{LAST_DAY_PREV_MONTH}'
)
"""}},
)
# 4) Refresh the materialised view
refresh = BigQueryInsertJobOperator(
task_id="refrescar_vista",
location=REGION,
configuration={"query": {"useLegacySql": False, "query": f"""
CALL BQ.REFRESH_MATERIALIZED_VIEW(
'{PROJECT}.{DATASET}.mv_ventas_diarias_sku')
"""}},
)
# 5) Announce that the report is ready
notify = PubSubPublishMessageOperator(
task_id="avisar_informe_listo",
project_id=PROJECT,
topic="informes-listos",
messages=[{
"data": b'{"informe":"mensual","estado":"completado"}',
"attributes": {
"tipo_evento": "informe_listo",
"periodo": FIRST_DAY_PREV_MONTH,
},
}],
)
check_data >> basket_analysis >> reports >> refresh >> notifyThe point being assessed is calculating the previous month with macros.ds_add and macros.ds_format instead of with datetime.now(). Running the DAG on 1 April, ds is 2026-04-01, ds_add(ds, -1) gives 2026-03-31 and formatting to %Y-%m-01 gives 2026-03-01. When reprocessing 1 February, the values will be January's. With dates calculated in real time, the reprocessing would be useless.
Solution 2
# informe-mensual.yaml
main:
params: [entrada]
steps:
- inicializar:
assign:
- proyecto: "alpinashop-datos"
- dataset: "alpinashop_analitica"
- region: "europe-west1"
- bucket: "alpinashop-datalake"
- mes: ${default(map.get(entrada, "mes"), text.substring(time.format(sys.now() - 2592000), 0, 7))}
- primer_dia: ${mes + "-01"}
- mes_compacto: ${text.replace_all(mes, "-", "")}
- calcular_ultimo_dia:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"SELECT CAST(LAST_DAY(DATE '" + primer_dia + "') AS STRING) AS ultimo"}
result: r_ultimo
- asignar_ultimo:
assign:
- ultimo_dia: ${r_ultimo.rows[0].f[0].v}
# 1) Check that there is data
- comprobar_datos:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"SELECT COUNT(*) AS n FROM `" + proyecto + "." + dataset + ".lineas_pedido` WHERE fecha_pedido BETWEEN DATE '" + primer_dia + "' AND DATE '" + ultimo_dia + "'"}
result: r_datos
- evaluar_datos:
switch:
- condition: ${int(r_datos.rows[0].f[0].v) == 0}
raise: ${"No order lines for month " + mes}
# 2) Dataproc Serverless: long-running operation
- lanzar_cesta:
call: http.post
args:
url: ${"https://dataproc.googleapis.com/v1/projects/" + proyecto + "/locations/" + region + "/batches?batchId=cesta-" + mes_compacto}
auth:
type: OAuth2
body:
pysparkBatch:
mainPythonFileUri: ${"gs://" + bucket + "/jobs/cesta_media.py"}
args:
- ${"--fecha-desde=" + primer_dia}
- ${"--fecha-hasta=" + ultimo_dia}
runtimeConfig:
version: "2.2"
environmentConfig:
executionConfig:
serviceAccount: "[email protected]"
subnetworkUri: "sn-datos-euw1"
result: r_batch
- esperar_cesta:
call: espera_batch
args:
proyecto: ${proyecto}
region: ${region}
batch_id: ${"cesta-" + mes_compacto}
# 3) Two queries in parallel
- informes:
parallel:
branches:
- margen:
steps:
- q_margen:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"CREATE OR REPLACE TABLE `" + proyecto + "." + dataset + ".agg_margen_mes` AS SELECT DATE '" + primer_dia + "' AS mes, pr.categoria, SUM(l.cantidad) AS unidades, ROUND(SUM(l.importe_linea),2) AS ventas_eur, ROUND(SUM(l.cantidad*c.coste_medio_eur),2) AS coste_eur FROM `" + proyecto + "." + dataset + ".lineas_pedido` l JOIN `" + proyecto + "." + dataset + ".productos` pr USING (sku) JOIN `" + proyecto + "." + dataset + ".costes_producto_erp` c USING (sku) WHERE l.fecha_pedido BETWEEN DATE '" + primer_dia + "' AND DATE '" + ultimo_dia + "' GROUP BY mes, pr.categoria"}
- sin_ventas:
steps:
- q_sin_ventas:
call: ejecuta_sql
args:
proyecto: ${proyecto}
sql: ${"CREATE OR REPLACE TABLE `" + proyecto + "." + dataset + ".agg_sin_ventas_mes` AS SELECT DATE '" + primer_dia + "' AS mes, pr.sku, pr.nombre, pr.categoria FROM `" + proyecto + "." + dataset + ".productos` pr WHERE pr.activo = TRUE AND NOT EXISTS (SELECT 1 FROM `" + proyecto + "." + dataset + ".lineas_pedido` l WHERE l.sku = pr.sku AND l.fecha_pedido BETWEEN DATE '" + primer_dia + "' AND DATE '" + ultimo_dia + "')"}
- devolver:
return:
mes: ${mes}
estado: "OK"
ejecuta_sql:
params: [proyecto, sql]
steps:
- lanzar:
call: googleapis.bigquery.v2.jobs.query
args:
projectId: ${proyecto}
body:
query: ${sql}
useLegacySql: false
location: "europe-west1"
timeoutMs: 300000
result: r
- devolver:
return: ${r}
# Polling the long-running operation: Workflows CANNOT wait
# 20 minutes in a single HTTP call (30 min limit, and besides
# the batch could take longer).
espera_batch:
params: [proyecto, region, batch_id]
steps:
- consultar:
call: http.get
args:
url: ${"https://dataproc.googleapis.com/v1/projects/" + proyecto + "/locations/" + region + "/batches/" + batch_id}
auth:
type: OAuth2
result: estado
- evaluar:
switch:
- condition: ${estado.body.state == "SUCCEEDED"}
return: ${estado.body}
- condition: ${estado.body.state == "FAILED"}
raise: ${"Dataproc batch failed: " + default(map.get(estado.body, "stateMessage"), "no detail")}
- condition: ${estado.body.state == "CANCELLED"}
raise: "Dataproc batch cancelled"
- esperar:
call: sys.sleep
args:
seconds: 30
next: consultargcloud workflows deploy alpinashop-informe-mensual \
--source=informe-mensual.yaml --location=europe-west1 \
--service-account="[email protected]"
gcloud scheduler jobs create http disparar-informe-mensual \
--location=europe-west1 \
--schedule="0 4 1 * *" \
--time-zone="Europe/Madrid" \
--uri="https://workflowexecutions.googleapis.com/v1/projects/alpinashop-datos/locations/europe-west1/workflows/alpinashop-informe-mensual/executions" \
--http-method=POST \
--oauth-service-account-email="[email protected]" \
--message-body='{"argument":"{}"}' \
--max-retry-attempts=3What is being assessed: the espera_batch subworkflow with its polling and sys.sleep. It is the key difference from Airflow, where DataprocCreateBatchOperator waits on its own. In Workflows you have to implement the loop by hand, and you have to do it properly: check the three terminal states (SUCCEEDED, FAILED, CANCELLED), not just the successful one, because otherwise a failed batch would leave the flow polling forever.
Solution 3
What happened, in chronological order:
Step 1 — The backfill on 24 February was the triggering cause. Two weeks were reprocessed. For each of the 14 logical dates, the DAG ran consolidar_pedidos, which uses INSERT INTO. Since the tables already contained those orders from the original runs, every order in those two weeks ended up duplicated in pedidos.
Step 2 — The aggregation amplified the problem and froze it. The query filters on WHERE l.fecha_pedido = CURRENT_DATE() - 1. During the backfill, CURRENT_DATE() was 24 February in all fourteen runs, because CURRENT_DATE() knows nothing about the logical date. In other words: all fourteen runs calculated the same day, 23 February, and wrote the same result fourteen times over the same partition.
Step 3 — And it kept going wrong every night. Here is the detail that explains the three weeks: since the query uses CREATE OR REPLACE TABLE with no logical-date filter on the write, every subsequent nightly run overwrote the table with whatever it calculated for "yesterday" according to CURRENT_DATE(). That should have kept updating itself… except that the resulting table keeps the previous history exactly as it was left after the backfill, and the old partitions stayed frozen with 24 February's data. Hence Lucía sees 24 February repeated in every partition since then.
Why the DAG showed green. Because no task failed. INSERT INTO does not raise an error when inserting duplicates: there are no primary keys in BigQuery. The aggregation query ran correctly. And the quality check only verified that the table was not empty — and it was not: it was full of incorrect data. Green does not mean correct; it means there were no exceptions. That distinction is the lesson of this exercise.
Fixes, one per problem:
A) INSERT INTO → MERGE. It makes the consolidation idempotent:
MERGE `alpinashop-datos.alpinashop_analitica.pedidos` AS d
USING (SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos_staging`) AS o
ON d.pedido_id = o.pedido_id AND d.fecha_pedido = o.fecha_pedido
WHEN MATCHED THEN UPDATE SET estado = o.estado, total_pedido = o.total_pedido
WHEN NOT MATCHED THEN INSERT ROWB) CURRENT_DATE() → {{ ds }}, and writing only the corresponding partition. With CREATE OR REPLACE TABLE the whole table is rewritten; the correct approach is to write only the partition for the logical date:
aggregate_sales = BigQueryInsertJobOperator(
task_id="ventas_por_categoria",
location=REGION,
configuration={
"query": {
"useLegacySql": False,
# Writes ONLY the partition for the logical date
"destinationTable": {
"projectId": PROJECT,
"datasetId": DATASET,
"tableId": "agg_ventas_categoria_dia${{ ds_nodash }}",
},
"writeDisposition": "WRITE_TRUNCATE",
"timePartitioning": {"type": "DAY", "field": "dia"},
"query": f"""
SELECT
l.fecha_pedido AS dia,
pr.categoria,
COUNT(DISTINCT l.pedido_id) AS pedidos,
SUM(l.cantidad) AS unidades,
ROUND(SUM(l.importe_linea), 2) AS ventas_eur
FROM `{PROJECT}.{DATASET}.lineas_pedido` AS l
JOIN `{PROJECT}.{DATASET}.productos` AS pr USING (sku)
WHERE l.fecha_pedido = DATE '{{{{ ds }}}}'
GROUP BY dia, pr.categoria
""",
}
},
)The $ decorator in agg_ventas_categoria_dia${{ ds_nodash }} is BigQuery's partition decorator: WRITE_TRUNCATE affects only that partition, not the table. So reprocessing 12 March fixes 12 March and touches no other day.
C) A real quality check. Not "there are rows", but business rules:
check_quality = BigQueryCheckOperator(
task_id="comprobar_calidad_datos",
location=REGION,
use_legacy_sql=False,
sql=f"""
WITH dia AS (
SELECT
SUM(pedidos) AS pedidos_dia,
SUM(ventas_eur) AS ventas_dia,
COUNT(*) AS filas
FROM `{PROJECT}.{DATASET}.agg_ventas_categoria_dia`
WHERE dia = DATE '{{{{ ds }}}}'
),
control_duplicados AS (
SELECT COUNT(*) AS duplicados FROM (
SELECT pedido_id
FROM `{PROJECT}.{DATASET}.pedidos`
WHERE fecha_pedido = DATE '{{{{ ds }}}}'
GROUP BY pedido_id
HAVING COUNT(*) > 1
)
)
SELECT
d.filas > 0 AND
d.pedidos_dia > 0 AND
d.pedidos_dia < 500 AND
d.ventas_dia > 0 AND
c.duplicados = 0
FROM dia AS d CROSS JOIN control_duplicados AS c
""",
)The duplicados = 0 condition is the one that would have caught the problem on the night of 24 February itself, instead of three weeks later.
D) Repair procedure. Fixing the code does not fix the already corrupted data:
-- 1) Deduplicate the orders table keeping the latest version
CREATE OR REPLACE TABLE `alpinashop-datos.alpinashop_analitica.pedidos`
PARTITION BY fecha_pedido
CLUSTER BY estado, canal AS
SELECT * EXCEPT(rn) FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY pedido_id, fecha_pedido
ORDER BY momento_pedido DESC) AS rn
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
)
WHERE rn = 1;# 2) Reprocess the aggregations for the affected period, now with the fixed DAG
gcloud composer environments run alpinashop-composer \
--location=europe-west1 dags backfill -- \
--start-date 2026-02-10 --end-date 2026-03-16 \
--reset-dagruns alpinashop_proceso_nocturnoThat backfill is now safe, because after fixes A and B every task is idempotent. Before, it was the cause of the problem. The full moral of the exercise: backfill is not dangerous; what is dangerous is a non-idempotent DAG, and the backfill merely reveals it.
Conclusion
You have seen why cron is not an orchestrator: it answers "what time is it?" when the right question is "what can run now, given what has happened?". And you have seen the concrete price of that confusion: an export that takes twenty minutes too long and a management committee staring at false sales figures.
You know the five things a real orchestrator brings — explicit dependencies, failure handling, observability, reproducibility and notification — and the three Google Cloud tools that cover them to different degrees.
You have learned Cloud Composer, managed Airflow, with its concepts: the DAG as an acyclic graph, tasks, operators that define what each one does, and sensors that wait for something to happen instead of betting on a time. You have written the complete DAG for AlpinaShop's nightly process: export from the read replica, a sensor in reschedule mode that genuinely waits for the file, a load with WRITE_TRUNCATE into staging, an idempotent MERGE into the final table, launching the Dataflow flex template and waiting for it to finish, two aggregations in parallel inside a TaskGroup, refreshing the materialised view and a quality check that makes the DAG fail before anybody looks at a faulty report. With exponential retries, execution_timeout, an SLA and notification.
You know the Google Cloud operators, XComs for small values — never for data — variables and connections with Secret Manager behind them, the trigger rules with all_done for the clean-ups that must run whatever happens, and the golden rule of reproducibility: {{ ds }} always, CURRENT_DATE() never, because whether a backfill repairs or wrecks depends on it.
And you have put the cost on the table without decoration: around €350 a month for a small environment, against the €15 the whole of AlpinaShop's data platform costs. The orchestrator would cost twenty times more than what it orchestrates. That is why you have set up the alternative: Workflows, declarative orchestration in YAML with no fixed cost, with parallel branches, retries with backoff, reusable subworkflows and explicit polling of long-running operations; triggered by Cloud Scheduler with the Madrid time zone so that the clock changes do not shift the process. You know its real limits — no native backfill, no sensors, no rich dashboard, no operator catalogue — and you know that with eight tasks they are bearable and with eighty they are not.
AlpinaShop's decision is settled and argued: start with Workflows and Cloud Scheduler, with objective criteria written in advance for jumping to Composer — more than ten interdependent flows, a recurring need for backfill, several people maintaining them, or the cleanest criterion of all: when the orchestrator drops below 10% of the cost of what it orchestrates. And knowing that the migration will throw nothing away, because the logic lives in the queries, the templates and the jobs; only who invokes them changes.
With this, the machinery is complete. The data comes in on its own, is transformed on its own, is aggregated on its own and is checked on its own, every night, with alerts if something fails.
And now the module's last problem appears, and it is not a technical one. There are now sixteen tables in alpinashop_analitica, half a dozen views, a lake with Parquet, staging tables, aggregates and a table called pedidos_evento that nobody remembers the reason for. When somebody from marketing asks "where is the conversion figure?", nobody can answer without opening BigQuery and hunting around. Nobody has written down what ventas_eur means exactly — whether it includes VAT, whether it nets off returns. Nobody knows whether email_cliente is still turning up somewhere it should not. And the dashboard Lucía promised management still does not exist: the data is perfect and nobody can see it.
In 04-07, Dataplex and Looker Studio, we will close the module with that. You will see what data governance is and why a small business needs it too: catalogue, lineage, quality, classification and lifecycle. You will set up lakes, zones and assets in Dataplex, document alpinashop_analitica with tags, define quality rules that check themselves — total_pedido never negative, sku always present — and use Sensitive Data Protection to discover where the emails and phone numbers are and de-identify them before exposing them, with the corresponding GDPR warning. And at last you will build AlpinaShop's management dashboard in Looker Studio, with its good practices for design, for cost and — above all — for permissions, including the classic "owner's credentials" mistake that turns a shared report into a data leak.
Google Cloud Platform (GCP) Course
Module 1: Introduction to Google Cloud Platform
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
