Every morning at 13:20 a cron job on my VPS imports holiday park accommodations from eleven affiliate XML feeds. Every morning it exited 0. The log said "klaar" (done). Nothing in Sentry, nothing in the mail.
For eight days, one of those eleven feeds imported exactly zero rows.
How I found it
Not from an alert. I was adding a column to a dashboard and noticed one provider's parks all had the same stale timestamp:
SELECT adverteerder,
MAX(laatste_feed_seen) AS laatst,
COUNT(*) AS rijen
FROM accommodaties
WHERE actief = 1
GROUP BY adverteerder
ORDER BY laatst;
+--------------+---------------------+-------+
| adverteerder | laatst | rijen |
+--------------+---------------------+-------+
| Landal | 2025-09-08 13:21:04 | 1126 |
| Roompot | 2025-09-16 13:22:11 | 1402 |
| EuroParcs | 2025-09-16 13:22:40 | 389 |
| ... | 2025-09-16 ... | ... |
Ten providers from yesterday. One from eight days ago, across 1126 rows. Nothing had errored. The feed had simply stopped matching anything my parser looked for.
Layer one: the country filter
The script starts by throwing away everything outside the Netherlands:
if eigenschap(p, 'country') != 'Nederland':
continue
The feed used to say Nederland. It now says NL. Every single product hit that continue on the first line of the loop, so the parser did its full run over 1887 products and wrote nothing. No parse error, because nothing got far enough to parse.
NL_CODES = ('NL', 'NLD', 'Nederland')
if eigenschap(p, 'country') not in NL_CODES:
continue
I expected that to be the whole bug. It was the first of four.
Layer two: renamed fields
With the filter fixed, products got through, and every one of them came out with nulls where the useful data should be. The provider had migrated the feed to a new schema and renamed four fields:
| old | new |
|---|---|
ParkCode |
parkID |
city |
place |
zipcode |
postal_code |
province |
region |
Any sensible parser asked for a missing field returns None. Mine returned None four times per product and wrote the row anyway, because every column except the key was nullable.
The region rename came with a value change too. Provinces arrived in English, underscored:
REGIO_MAP = {
'North_Holland': 'Noord-Holland',
'South_Holland': 'Zuid-Holland',
'Wadden': 'Friesland',
}
def landal_provincie(p):
r = eigenschap(p, 'region')
return REGIO_MAP.get(r, r)
Wadden is not a province at all, it is an island group. It maps to Friesland for my purposes. A straight string pass-through would have created a province page for a thing that is not a province.
Layer three: the field that kept its name and changed its meaning
This was the expensive one, and it is the reason I am writing this post.
<name> still existed. It was still a string. It still looked completely reasonable:
<product>
<name>Amerongse Berg</name>
...
In the old feed, <name> was the accommodation name: "Veluwelodge XL 6 personen". In the new feed, <name> is the park name, and the accommodation name moved to a different element.
So my parser ran clean, my import reported success, and it inserted 1887 accommodations all called "Amerongse Berg". Rows where every unique thing about the accommodation had been replaced by the name of the site it sits on.
I shipped that. It went live for about twenty minutes before I noticed a category page listing the same lodge forty times.
DELETE FROM accommodaties
WHERE adverteerder = 'Landal'
AND aangemaakt_op >= CURDATE();
The fix reads both, preferring the explicit park field and falling back to the old element:
code = eigenschap(p, 'parkID') or vol_id.split('_')[0]
naam = eigenschap(p, 'parkName') or tekst_van(p, 'name')
A renamed field fails loudly. A repurposed field fails silently, passes every type check you have, and gets committed.
Layer four: the IDs changed
The last layer: my primary key for an accommodation was a SHA1 hash of the old product ID. The new feed issues different product IDs. Every row in the new import was therefore new, and the 1126 existing rows were orphans with no path back.
What survived the migration was the affiliate URL, which carries both IDs as query params. So I rebuilt the key from the URL:
def bestaande_landal_sleutels(cur):
import re, urllib.parse
cur.execute("SELECT affiliate_url FROM accommodaties "
"WHERE adverteerder=%s AND actief=1", (ADVERTEERDER,))
uit = set()
for (url,) in cur.fetchall():
u = urllib.parse.unquote(url or '')
mp = re.search(r'park=([A-Za-z0-9]+)', u)
ma = re.search(r'accommodation=([A-Za-z0-9_-]+)', u)
if mp and ma:
uit.add(f"{mp.group(1)}_{ma.group(1)}")
return uit
A one-off migration script matched old rows to new IDs through that set: 1033 updated, 93 deactivated as genuinely gone, and 114 duplicates surfaced that had been quietly sitting in the table for months. The duplicates were not caused by this bug. The bug just made me look.
The brake I added afterwards
The other half of the fix is a brake on the importer itself. New products are no longer inserted blind; they have to already exist, or they get counted and skipped:
if u['acc_id'] not in bestaand:
telling['nieuw_overgeslagen'] = telling.get('nieuw_overgeslagen', 0) + 1
continue
That counter is the actual alert. A healthy run skips a handful. A run that skips 1800 means the feed changed shape again, and the number lands in the log instead of 1800 junk rows landing in the database.
The script I am glad I had not written yet
The same week, I had been planning a cleanup job to deactivate accommodations that disappear from their feed. Stale inventory is a real problem: a lodge that no longer exists is a dead link and a bad search result.
If that script had existed during those eight days, it would have deactivated all 1126 Landal accommodations. Correctly, by its own logic. They were absent from the parse result on every single run.
So when I did write it, the rule went in first:
DAGEN = 5 # comfortably above the daily cron interval
MAX_PCT = 20 # more than this share of one source: skip the source
MAX_ABS = 50 # and never more than this many at once per source
Mass disappearance is a parser symptom, not an inventory one. Real inventory decays a few rows at a time. If twenty percent of a provider vanishes overnight, the thing that broke is my code, and the correct response is to refuse to act and say so.
Ordering matters as much as the thresholds. The deactivation job runs at 13:30, after every fetcher has finished (last one at 13:25) and before the page generator at 13:35. Run it first and it deactivates based on yesterday's data; run it last and it publishes pages for accommodations it is about to retire.
What I took from it
Exit code 0 means the script finished, not that it worked. Mine finished eight times, beautifully, having written nothing.
Count what you skip, not just what you write. Every continue in a parse loop is a silent discard. One counter per skip reason, logged every run, would have caught this on day one.
Freshness is a first-class metric. MAX(laatste_feed_seen) per source, compared against the cron interval, is a four-line query and the single highest-value check in the whole pipeline. It is now the thing I look at first.
A field that keeps its name and changes its meaning is the worst case. No type error, no missing key, no exception. Just 1887 rows of plausible nonsense. The only defence is asserting on values, not shapes: if every product in a batch shares one name, something is wrong regardless of whether it validates.
Write the circuit breaker before the destructive job, not after. I got lucky on the ordering of two tasks on a list. That is not a strategy.
If you want the practical version rather than the incident, I wrote the three checks up separately: Three checks that catch a silently broken data pipeline.
The pipeline feeds Parkzie, a Dutch holiday park comparison site I run on my own. Eleven feeds, three languages, one person, which is exactly the setup where a silent failure gets to live for eight days.
Top comments (2)
Follow-up for anyone who wants the practical version rather than the incident: same three guards, written as something you can apply on Monday instead of a story about what went wrong.
mavemedia.hashnode.dev/three-check...
I pulled these three guards out into a small repo, no dependencies, one file:
github.com/mavemedia/feed-guard
Some comments may only be visible to logged-in visitors. Sign in to view all comments.