<?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[Mave Media]]></title><description><![CDATA[Mave Media]]></description><link>https://mavemedia.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Mave Media</title><link>https://mavemedia.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 11:13:14 GMT</lastBuildDate><atom:link href="https://mavemedia.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Three checks that catch a silently broken data pipeline]]></title><description><![CDATA[A scheduled job that crashes is the easy case. You get a stack trace, an alert fires, and you fix it before lunch.
A scheduled job that finishes cleanly while doing nothing is the hard case. There is ]]></description><link>https://mavemedia.hashnode.dev/three-checks-that-catch-a-silently-broken-data-pipeline</link><guid isPermaLink="true">https://mavemedia.hashnode.dev/three-checks-that-catch-a-silently-broken-data-pipeline</guid><category><![CDATA[Python]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[Devops]]></category><category><![CDATA[monitoring]]></category><dc:creator><![CDATA[Mave Media]]></dc:creator><pubDate>Thu, 08 Oct 2026 07:52:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac7472986e412b9eb11c686/175ad26c-bc6c-4d68-9982-ab0627c60558.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A scheduled job that crashes is the easy case. You get a stack trace, an alert fires, and you fix it before lunch.</p>
<p>A scheduled job that finishes cleanly while doing nothing is the hard case. There is no exception to catch, no alert to configure, and every dashboard you own reports a successful run. Mine did exactly that for eight days: eleven affiliate feeds, one of them importing zero rows every morning, exit code 0 every time. If you want the incident itself, I wrote it up separately: <a href="https://dev.to/mavemedia/my-pipeline-ran-green-for-8-days-while-importing-nothing-mec">My pipeline ran green for 8 days while importing nothing</a>.</p>
<p>This post is the other half. Three checks I added afterwards, why each exists, and where they belong in the schedule. They are small, they have no dependencies, and together they close the gap between "the script finished" and "the script worked".</p>
<h2>1. Measure freshness, not success</h2>
<p>Start here. This is four lines of SQL and it is the single highest-value check in any scheduled import.</p>
<p>Your job writes rows. Those rows carry a timestamp. So instead of asking whether the run succeeded, ask how old each source's newest row is:</p>
<pre><code class="language-sql">SELECT source,
       MAX(last_seen) AS newest,
       COUNT(*)       AS rows
FROM   items
WHERE  active = 1
GROUP  BY source;
</code></pre>
<p>Run that after every import and compare each result against your cron interval. A source that is suddenly days behind the others has stopped importing, whatever the exit code said. In my case ten sources showed yesterday and one showed eight days ago, across 1126 rows. That one query was the entire diagnosis.</p>
<p>Wrapped up:</p>
<pre><code class="language-python">from datetime import timedelta
from feed_guard import check_freshness

stale = check_freshness(
    cursor,
    table="items",
    source_column="source",
    seen_column="last_seen",
    max_age=timedelta(days=2),
    where="active = 1",
)

for s in stale:
    log.error("stale source: %s", s)
</code></pre>
<p>Set <code>max_age</code> comfortably above your cron interval so a single skipped run does not page you, but tight enough that a genuinely dead source shows up the next day.</p>
<p>The reason this works where exception handling does not: it measures the outcome rather than the process. A parser can run to completion, log nothing, and write nothing. The timestamp cannot lie about that.</p>
<h2>2. Count what you skip</h2>
<p>Every <code>continue</code> in a parse loop is a silent discard. Most pipelines have several, and none of them are counted.</p>
<p>Mine began with a country filter. The feed used to say <code>Nederland</code> and changed to <code>NL</code>, so every product hit <code>continue</code> on the first line of the loop. The parser ran over 1887 products, discarded all 1887, and reported success.</p>
<p>Had I been counting discards by reason, the log would have said so on day one:</p>
<pre><code class="language-python">from feed_guard import SkipCounter

skips = SkipCounter()

for product in feed:
    if product.get("country") not in NL_CODES:
        skips.skip("country not NL")
        continue
    if not product.get("id"):
        skips.skip("no id")
        continue
    write(product)
    skips.kept()

log.info(skips.report())
# seen=1887 kept=0 skipped=1887 (100%) | country not NL=1887
</code></pre>
<p>That line is the alert. You do not need a monitoring stack to act on it:</p>
<pre><code class="language-python">if skips.looks_broken():
    raise RuntimeError(f"parser discarded almost everything: {skips.report()}")
</code></pre>
<p>The rule of thumb: a healthy run skips a handful of rows for boring reasons. A run that skips almost everything is not a quiet day upstream, it is a parser that no longer matches its input.</p>
<h2>3. Refuse bulk changes that are too large to be real</h2>
<p>This one is about the cleanup job, not the import.</p>
<p>Stale inventory is a real problem. Records that vanish from a feed should eventually be deactivated, or you are serving dead links. So most pipelines grow a cleanup job: anything not seen in N days gets switched off.</p>
<p>Now put that job next to the failure above. For eight days, every row from that source was absent from the parse result. A cleanup job doing its work correctly, by its own logic, would have deactivated all 1126 of them. The outage would have gone from invisible to catastrophic, and it would have looked like my data was simply gone.</p>
<p>The fix is a threshold the job refuses to cross:</p>
<pre><code class="language-python">from feed_guard import guard_bulk_change, BulkChangeRefused

for source, gone in disappeared.items():
    try:
        guard_bulk_change(
            total=totals[source],
            affected=len(gone),
            max_pct=20,
            max_abs=50,
            label=source,
        )
    except BulkChangeRefused as exc:
        log.error("%s", exc)
        continue
    deactivate(gone)
</code></pre>
<p>The reasoning behind the numbers: real inventory decays a few rows at a time. Listings close, products sell out, items are withdrawn. It is gradual, and it is uncorrelated across sources. A fifth of one source disappearing overnight is not that pattern. It is a parser symptom, or an upstream outage, and the correct response is to do nothing and say so loudly.</p>
<p>Note that the guard is per source, not global. Twenty percent of your whole dataset is a different event from twenty percent of one feed, and the per-source version catches the case where ten sources are fine and one is broken. That is the case you will actually hit.</p>
<h2>Ordering matters as much as thresholds</h2>
<p>A cleanup job with perfect thresholds still does the wrong thing if it runs at the wrong moment.</p>
<p>Mine sits between the fetchers and the publisher:</p>
<pre><code class="language-plaintext">13:20  fetchers start
13:25  last fetcher finishes
13:30  cleanup runs
13:35  page generator runs
</code></pre>
<p>Run the cleanup before the fetchers and it acts on yesterday's data. Run it after the publisher and you generate pages for records you are about to retire. The window between "everything that writes has finished" and "anything that reads begins" is the only correct slot, and it is worth writing down explicitly rather than discovering it later.</p>
<h2>Putting it together</h2>
<p>Three checks, in the order you should add them:</p>
<ol>
<li><p><strong>Freshness per source.</strong> Four lines of SQL. Catches the whole class of silent failures and costs nothing.</p>
</li>
<li><p><strong>Skip counters in every parse loop.</strong> Turns a silent discard into a number in your log.</p>
</li>
<li><p><strong>A circuit breaker before anything destructive.</strong> Written before the destructive job, not after it.</p>
</li>
</ol>
<p>The code is in a small repo, no dependencies, one file: <a href="https://github.com/mavemedia/feed-guard">feed-guard</a>. Copy the file into your project, or just copy the ideas. The ideas are the point.</p>
<p>One last thing worth saying plainly, because it took me eight days to learn it: <strong>exit code 0 means the script finished.</strong> Whether it did its job is a separate question, and the only way to answer it is to measure the result rather than the run.</p>
<hr />
<p>I run <a href="https://parkzie.com/en/">Parkzie</a>, a Dutch holiday park comparison site, on my own. Eleven feeds, three languages, one person, which is exactly the setup where a silent failure gets to live for eight days.</p>
]]></content:encoded></item></channel></rss>