<?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"><channel><title><![CDATA[BIX Tech]]></title><description><![CDATA[BIX Tech]]></description><link>https://bix-tech.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a95fb1cc47ae75131ab8412/740b4ee7-78db-4bc4-8986-ce5c8b99ad96.png</url><title>BIX Tech</title><link>https://bix-tech.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 04:49:01 GMT</lastBuildDate><atom:link href="https://bix-tech.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Open or closed model? There is a third option]]></title><description><![CDATA[Open or closed? The question shows up in every architecture review, and it usually gets settled with a benchmark chart and a price-per-token comparison. But it often gives the wrong answer, because th]]></description><link>https://bix-tech.hashnode.dev/open-or-closed-model-there-is-a-third-option</link><guid isPermaLink="true">https://bix-tech.hashnode.dev/open-or-closed-model-there-is-a-third-option</guid><category><![CDATA[AI]]></category><category><![CDATA[open source]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[llm]]></category><category><![CDATA[Developer]]></category><dc:creator><![CDATA[Laura Cristina Chicovis da Cruz]]></dc:creator><pubDate>Thu, 10 Sep 2026 01:13:55 GMT</pubDate><content:encoded><![CDATA[<p>Open or closed? The question shows up in every architecture review, and it usually gets settled with a benchmark chart and a price-per-token comparison. But it often gives the wrong answer, because the two options on the table are not the two you have.</p>
<p>There is a third sitting between them, and for most workloads it wins.</p>
<h2>Why the binary is badly posed</h2>
<p>The usual reasoning runs like this. An H100 rents for a few dollars an hour, while a frontier API charges a markup per token on that same hardware. So you run the weights yourself and keep the margin someone else is taking.</p>
<p>The catch is that "using an open model" and "operating an open model" got collapsed into one choice. So separate them, and the third option appears.</p>
<h2>The three shapes</h2>
<ol>
<li><p><strong>Closed frontier API.</strong> Variable cost, zero operations, no access to the weights. Roughly $2.50 to $15 per million tokens (packet.ai, jul/2026).</p>
</li>
<li><p><strong>Hosted open-weight API.</strong> Variable cost, zero operations, and the same weights you would run. Roughly $0.07 to $0.90 per million tokens for Llama 4 and DeepSeek V4 class models (packet.ai, jul/2026).</p>
</li>
<li><p><strong>Self-hosted open weights.</strong> Fixed monthly cost, full control, and an operational surface you now own. Llama 4 70B on an H100 at batch=8 comes to about $0.18 per million output tokens (packet.ai, jul/2026).</p>
</li>
</ol>
<p>Rows two and three run the same model, in the same order of magnitude. So the saving that justifies buying GPUs is not the one against the frontier API, it is the one against a provider already serving those weights, and there it mostly disappears.</p>
<h2>The number that decides it is utilization</h2>
<p>That $0.18 figure assumes the GPU runs at 85% utilization with vLLM continuous batching, against 30 to 40% for naive serving (packet.ai, jul/2026). Meanwhile, average GPU utilization across production Kubernetes fleets sits at 5%, and the best cluster in that dataset, a 136-node H200 deployment, reached 49% (Cast AI, State of Kubernetes Optimization Report 2026).</p>
<p>That gap is the whole argument. A rented GPU bills the same idle as saturated, so cost per token scales inversely with utilization. Drop from 85% to 5% and the same setup costs roughly seventeen times more per token.</p>
<p>Then add operations. Version upgrades, OOM debugging, monitoring and capacity planning run 10 to 20 hours a month, between $1,500 and $4,000 that never shows up in the GPU bill (packet.ai, jul/2026).</p>
<p>Which is why published break-even points disagree so much. Against managed frontier APIs, self-hosting on reserved capacity breaks even around 2 to 5 million tokens a day over 12 months (packet.ai, jul/2026). Against a hosted endpoint running the same weights, it moves out to 50 million or more (howaiworks, jul/2026).</p>
<p>And none of this is unique to inference. It is the same trap as provisioned capacity in a data platform, easier to see in <a href="https://bixtech.ai/microsoft-fabric-cost-optimization/?utm_source=dev.to&amp;utm_medium=guestpost&amp;utm_campaign=okr_backlinks_q3_2026">Microsoft Fabric capacity units</a>, where the meter runs on what you reserved and not on what you used.</p>
<h2>Run it on your own numbers</h2>
<pre><code class="language-python">HOURS_PER_MONTH = 730

def monthly_self_host(gpu_hourly, n_gpus, ops_multiplier):
    """Fixed. Billed by the hour, never by the token."""
    return gpu_hourly * HOURS_PER_MONTH * n_gpus * ops_multiplier

def breakeven_tokens(gpu_hourly, n_gpus, ops_multiplier, price_per_mtok):
    """Monthly token volume where self-hosting matches the API bill."""
    fixed = monthly_self_host(gpu_hourly, n_gpus, ops_multiplier)
    return fixed / price_per_mtok * 1_000_000

def monthly_capacity(tokens_per_sec, n_gpus):
    """Ceiling. Use sustained throughput measured at your real batch size."""
    return tokens_per_sec * n_gpus * 3600 * HOURS_PER_MONTH
</code></pre>
<p>Run <code>breakeven_tokens</code> twice, once against your frontier API price and once against a hosted endpoint serving the model you intend to run. Then check both against <code>monthly_capacity</code>, because a break-even you cannot reach is not a break-even.</p>
<ul>
<li><p><strong>Below your volume and inside capacity.</strong> Self-hosting has a case, so load test the throughput figure before anyone reserves an instance.</p>
</li>
<li><p><strong>Above capacity.</strong> Reaching it means adding GPUs, which raises the fixed cost, which pushes break-even further out. At that price the lines never meet.</p>
</li>
<li><p><strong>Far above your volume, measured against the hosted endpoint.</strong> The open model wins and running it yourself does not.</p>
</li>
</ul>
<h2>What the math does not settle</h2>
<p>Cost is loud, but compliance is louder. Data residency, contractual confidentiality, a no-training guarantee or a hard latency floor can close the question before the spreadsheet opens. That bites hardest in Europe, where routing inference through US platforms raises transfer questions under Chapter V of the GDPR (Lyceum Technology, ago/2026). If the weights have to stay inside your network, break-even is beside the point.</p>
<p>Although it cuts the other way too. Self-hosting to satisfy a requirement that a zero-retention agreement already covers buys permanent operational load for a guarantee you had on paper.</p>
<h2>So, three options or one routing rule?</h2>
<p>In practice it lands as a split. A small open model takes the high-volume, well-shaped work, so classification, extraction and routing, while the frontier API stays reserved for reasoning it cannot handle. Cost drops because the cheap path absorbs the traffic, and quality holds because the expensive path catches the hard cases.</p>
<p>Which turns open versus closed into a routing decision made per workload, rather than an architecture decision made once and defended for two years.</p>
]]></content:encoded></item><item><title><![CDATA[How to integrate Apache Airflow with OpenLineage for end-to-end traceability]]></title><description><![CDATA[By the end of this walkthrough, every DAG run in your Airflow instance emits structured lineage events that name the exact tables each task read and wrote, and you can open a graph and answer "which u]]></description><link>https://bix-tech.hashnode.dev/how-to-integrate-apache-airflow-with-openlineage-for-end-to-end-traceability</link><guid isPermaLink="true">https://bix-tech.hashnode.dev/how-to-integrate-apache-airflow-with-openlineage-for-end-to-end-traceability</guid><category><![CDATA[airflow]]></category><category><![CDATA[dataengineering]]></category><dc:creator><![CDATA[Laura Cristina Chicovis da Cruz]]></dc:creator><pubDate>Thu, 03 Sep 2026 00:11:54 GMT</pubDate><content:encoded><![CDATA[<p>By the end of this walkthrough, every DAG run in your Airflow instance emits structured lineage events that name the exact tables each task read and wrote, and you can open a graph and answer "which upstream job produced this number" without grepping a single scheduler log.</p>
<p>That is the whole promise. No manual documentation, no lineage spreadsheet that goes stale in three weeks. The orchestrator reports what it actually did, while it is doing it.</p>
<p>The setup itself is short. What follows is ordered to surface the three failures that usually show up first, before they cost you an afternoon of guessing.</p>
<h2>Prerequisites and versions</h2>
<ul>
<li><p>Apache Airflow 2.11.0 or later, or any Airflow 3.x release. That is the minimum version supported by the current provider distribution.</p>
</li>
<li><p>Python 3.9 to 3.12.</p>
</li>
<li><p>Docker, to run a lineage backend locally.</p>
</li>
<li><p>A Postgres connection in Airflow (<code>postgres_default</code>) if you want to reproduce the SQL example exactly.</p>
</li>
</ul>
<p>The two packages that matter: the <a href="https://airflow.apache.org/docs/apache-airflow-providers-openlineage/stable/index.html">Airflow OpenLineage provider</a> extracts Airflow metadata and turns it into events, and <code>openlineage-python</code> transmits them. The client can be upgraded independently of the provider, which is useful when you need a transport fix without touching your Airflow version.</p>
<h2>Step 1: the event model, before you install anything</h2>
<p>OpenLineage has three objects and one extension mechanism. Skipping this part is why most first integrations produce an empty graph.</p>
<ul>
<li><p><strong>Job</strong>: something that runs. Your DAG is a job, and each task is also a job.</p>
</li>
<li><p><strong>Run</strong>: one execution of a job, with a unique run ID.</p>
</li>
<li><p><strong>Dataset</strong>: something read or written. Identified by a <code>namespace</code> and a <code>name</code>.</p>
</li>
<li><p><strong>Facet</strong>: an atomic block of metadata attached to any of the above. Schema, SQL text, column-level lineage, run state, and your own custom fields all arrive as facets, and the <a href="https://openlineage.io/docs/">OpenLineage specification</a> lists the standard ones.</p>
</li>
</ul>
<p>Events fire on state transitions: <code>START</code>, <code>RUNNING</code>, <code>COMPLETE</code>, <code>FAIL</code>, <code>ABORT</code>, <code>OTHER</code>. Lineage is reconstructed downstream by joining datasets across runs, which means dataset identity is the thing that makes or breaks the graph. More on that later, because it is the most common source of a graph with nodes and no edges.</p>
<h2>Step 2: install the provider</h2>
<pre><code class="language-bash">pip install apache-airflow-providers-openlineage
</code></pre>
<p>Official Airflow Docker images may already ship it. Check before adding it to your requirements file:</p>
<pre><code class="language-bash">airflow providers list | grep openlineage
</code></pre>
<p>Nothing is emitted yet. The provider stays silent until it knows where to send events.</p>
<h2>Step 3: point it at a transport</h2>
<p>Start with the console transport. It writes events to the task logs, costs nothing to run, and tells you immediately whether extraction works at all.</p>
<pre><code class="language-bash">export AIRFLOW__OPENLINEAGE__TRANSPORT='{"type": "console"}'
</code></pre>
<p>Once you see events in the logs, move to a real backend. <a href="https://github.com/MarquezProject/marquez">Marquez</a> is one option here, and any OpenLineage-compatible backend works. It is the reference implementation of the standard, which makes it the fastest way to get a lineage UI running:</p>
<pre><code class="language-bash">git clone https://github.com/MarquezProject/marquez
cd marquez
./docker/up.sh
</code></pre>
<p>The API listens on port 5000, the admin interface on 5001, and the web UI on 3000. On macOS, port 5000 is reserved by the operating system, so run <code>./docker/up.sh --api-port 9000</code> and adjust the URL below.</p>
<p>Now switch the transport:</p>
<pre><code class="language-bash">export AIRFLOW__OPENLINEAGE__TRANSPORT='{"type": "http", "url": "http://localhost:5000", "endpoint": "api/v1/lineage"}'
export AIRFLOW__OPENLINEAGE__NAMESPACE='airflow-local'
</code></pre>
<p>The same thing in <code>airflow.cfg</code>:</p>
<pre><code class="language-ini">[openlineage]
transport = {"type": "http", "url": "http://localhost:5000", "endpoint": "api/v1/lineage"}
namespace = airflow-local
disabled = False
</code></pre>
<p>Set the namespace deliberately. It logically separates producers, so a staging Airflow and a production Airflow do not merge into one graph and lie to you. If you leave it unset, everything lands in <code>default</code>.</p>
<p>For anything beyond local, do not put credentials in <code>airflow.cfg</code>. The provider accepts a Generic Airflow connection ID holding the transport config, including auth, in the connection extra.</p>
<h2>Step 4: run a DAG that actually produces lineage</h2>
<p>SQL operators are the best place to start, because the provider parses the query and derives inputs, outputs, and column-level relationships without you writing anything:</p>
<pre><code class="language-python"># dags/openlineage_demo.py
from datetime import datetime

from airflow import DAG
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator

with DAG(
    dag_id="openlineage_demo",
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:
    build_daily_orders = SQLExecuteQueryOperator(
        task_id="build_daily_orders",
        conn_id="postgres_default",
        sql="""
            CREATE TABLE IF NOT EXISTS analytics.daily_orders AS
            SELECT
                o.order_date,
                c.region,
                COUNT(*)      AS order_count,
                SUM(o.amount) AS revenue
            FROM raw.orders o
            JOIN raw.customers c ON c.customer_id = o.customer_id
            GROUP BY o.order_date, c.region;
        """,
    )
</code></pre>
<p>Trigger it. The resulting event should list <code>raw.orders</code> and <code>raw.customers</code> as inputs and <code>analytics.daily_orders</code> as the output, with a column-level facet mapping <code>revenue</code> back to <code>o.amount</code>.</p>
<h2>Step 5: verify it worked</h2>
<p>Three checks, in this order.</p>
<p><strong>Look at the graph.</strong> Open <code>http://localhost:3000</code>, find the namespace you configured, and confirm the two source tables connect to the output table.</p>
<p><strong>Check which tasks are even reporting.</strong> This is the diagnostic that is easiest to miss. The <code>DagRun</code> <code>START</code> event carries an <code>AirflowJobFacet</code> listing every task in the DAG, each with an <code>emits_ol_events</code> boolean. That tells you ahead of time which operators will stay invisible, instead of leaving you to guess why half the graph is missing.</p>
<p><strong>Understand the silence.</strong> An <code>EmptyOperator</code> emits nothing by default, because Airflow does not schedule it the way it schedules real work. Add an <code>on_execute</code> or <code>on_success</code> callback, or a task outlet, if you need it represented. When task-level detail does not matter, the <code>DagRun</code> <code>COMPLETE</code> event carries an <code>AirflowStateRunFacet</code> with the state of every task in the run.</p>
<h2>Step 6: cover the operators that report nothing</h2>
<p>Automatic extraction covers SQL operators and many provider operators. Your own operators report nothing until you tell them what they touch.</p>
<p>For operators you own, implement the OpenLineage methods directly:</p>
<pre><code class="language-python">from airflow.models import BaseOperator


class S3ToWarehouseOperator(BaseOperator):
    def __init__(self, *, source_bucket, source_key, target_table, **kwargs):
        super().__init__(**kwargs)
        self.source_bucket = source_bucket
        self.source_key = source_key
        self.target_table = target_table

    def execute(self, context):
        ...  # your copy logic

    def get_openlineage_facets_on_complete(self, task_instance):
        # import locally: top-level Airflow imports here can be cyclical
        # and make extraction fail silently
        from airflow.providers.common.compat.openlineage.facet import Dataset
        from airflow.providers.openlineage.extractors import OperatorLineage

        return OperatorLineage(
            inputs=[
                Dataset(namespace=f"s3://{self.source_bucket}", name=self.source_key)
            ],
            outputs=[
                Dataset(namespace="postgres://warehouse:5432", name=self.target_table)
            ],
        )
</code></pre>
<p>Rules worth internalizing:</p>
<ul>
<li><p>You must implement at least one of <code>get_openlineage_facets_on_start()</code> or <code>get_openlineage_facets_on_complete(ti)</code>. If <code>on_complete</code> is missing, the provider falls back to <code>on_start</code>. There is also <code>get_openlineage_facets_on_failure(ti)</code>, which by default reuses the <code>on_complete</code> logic.</p>
</li>
<li><p>Prefer <code>on_complete</code> whenever the real dataset names are only resolved during <code>execute</code>. Reporting a wildcard path on start and never correcting it produces a confident, wrong graph.</p>
</li>
<li><p>Import OpenLineage objects inside the method, never at module level. The listener is instantiated when the worker starts, so a top-level Airflow import can become circular and kill extraction without an obvious error.</p>
</li>
</ul>
<p>For third-party operators you cannot modify, write a custom extractor and register it:</p>
<pre><code class="language-bash">export AIRFLOW__OPENLINEAGE__EXTRACTORS='plugins.extractors.MyCustomExtractor'
</code></pre>
<h2>Step 7: attach your own context</h2>
<p>Since provider version 1.10.0, you can inject arbitrary run facets without touching operator code. Write a function that accepts the task instance and returns a facet dictionary, then register the import paths, separated by semicolons:</p>
<pre><code class="language-bash">export AIRFLOW__OPENLINEAGE__CUSTOM_RUN_FACETS='plugins.ol_facets.ownership_facet'
</code></pre>
<p>This is how you get team, cost center, or change-ticket ID onto every event, which turns "who owns this broken pipeline" from a Slack thread into a filter.</p>
<h2>What breaks in production</h2>
<p>Four settings and one habit account for most of the pain.</p>
<ul>
<li><p><code>include_full_task_info</code>: tempting, and expensive. With it on, all serializable task parameters go into the event. Depending on what you pass to your tasks, single events can reach megabytes.</p>
</li>
<li><p><code>execution_timeout</code>: cap how long extraction may run so a slow lineage call never becomes a pipeline incident.</p>
</li>
<li><p><code>dag_state_change_process_pool_size</code>: processes the scheduler uses to handle DAG state changes asynchronously. Worth tuning on busy instances.</p>
</li>
<li><p><code>emission_policy</code>: the current way to control what gets emitted. The older <code>selective_enable</code> and <code>disable_source_code</code> flags are deprecated in its favor.</p>
</li>
<li><p><strong>Naming discipline</strong>: lineage joins on dataset identity. If one job writes <code>analytics.daily_orders</code> and another reads <code>ANALYTICS.DAILY_ORDERS</code>, you get two nodes and no edge. Fix the convention (<code>database.schema.table</code>, environment-scoped namespaces) before you scale, because rewriting identities after the fact means reprocessing history.</p>
</li>
</ul>
<h2>Where lineage stops being the answer</h2>
<p>What you have now is operational lineage: what ran, what it read, what it wrote, and whether it failed. That is enough to trace an incident backward and to run impact analysis before a schema change.</p>
<p>It is not enough to answer who owns a dataset, whether it is certified, what "active customer" means in business terms, or where PII flows. Those live in a metadata platform, and OpenLineage events are the input to it rather than a replacement for it. If that is the direction you are heading, this breakdown of <a href="https://bixtech.ai/datahub-and-openlineage-a-modern-blueprint-for-data-governance-and-end-to-end-lineage/?utm_source=hashnode&amp;utm_campaign=backlinks">data governance and end-to-end lineage with a metadata catalog</a> covers how the collection layer and the governance layer divide the work.</p>
<p>The order matters, though. Emit first, catalog second. A catalog fed by hand ages exactly as fast as the documentation you were trying to replace.</p>
]]></content:encoded></item></channel></rss>