Skip to content
Guilherme Nogueira
Go back

Data Does Not Page You. It Just Goes Quietly Wrong.

8 min read

When a service breaks, it has the decency to tell you. Error rates climb, a dashboard goes red, a probe fails, someone gets paged, and the whole machinery of on-call wakes up to deal with it. It is loud, and loud is a feature.

Data does not do you that courtesy. When data breaks, the pipeline usually keeps reporting success, the dashboards stay green, and nobody gets paged. You find out weeks later, when a human notices that a number looks wrong and asks an innocent question that ruins your afternoon.

This is the story of one of those. A replication pipeline that was healthy by every metric it exposed, quietly writing NULL into a column that absolutely could not be NULL.

On the left, a service fails loudly with a red alert and a ringing alarm. On the right, a database looks green and healthy on the surface while a quiet crack spreads unnoticed inside it.

Table of contents

Open Table of contents

The short version

The symptom arrived as a question, not an alert

There was no incident. There was a message. Someone looking at an analytics report noticed a bucket of rows that belonged to no tenant at all, which in a multi-tenant system is roughly as alarming as finding money in your account that has no owner. Nice, until you think about it for two seconds.

That is the first thing worth sitting with. The failure had been happening for a while, and the first detector in the entire stack was a person’s eyebrow. No monitor caught it, because every monitor we had was pointed at the pipeline, and the pipeline was fine.

NULLs where a tenant should be

The shape of the problem was easy to confirm and deeply unpleasant. On the target, a meaningful slice of rows had tenant_id IS NULL. On the source, those exact rows had a perfectly good tenant.

-- on the target: rows that should be impossible
SELECT COUNT(*) FROM orders WHERE tenant_id IS NULL;
-- a number that should be zero, and was very much not zero

In a multi-tenant database, tenant_id is not just another column. It is the thing that keeps one customer’s data from bleeding into another’s. Every tenant-scoped query filters on it. A NULL there does not throw an error. It just silently drops out of every WHERE tenant_id = ? filter, which means the data is both present and invisible, wrong in the quietest possible way.

The pipeline was healthy the entire time

Here is the part that makes this a data problem and not a plumbing problem. The replication was, by its own account, perfect. Change data capture was running, latency was low, and the error count sat at a proud zero.

CDC status:        running
Replication lag:   low
Errors:            0
Tables:            all "in sync"

Everything I would normally watch said green. And every one of those signals was telling the truth about the pipeline, while telling me nothing at all about the data flowing through it. Pipeline health and data health are two different questions, and I was only asking one of them.

Root cause: one binlog setting nobody was watching

The trail led to MySQL’s binary log, and specifically to binlog_row_image.

For row-based replication, MySQL writes a “before” and “after” image of each changed row to the binlog. The binlog_row_image setting decides how much of the row it writes:

MINIMAL is smaller and faster, and it is completely fine for MySQL-to-MySQL replication, because the replica already has the rest of the row. The trouble is that change-data-capture tools like AWS DMS read that same binlog and expect the full picture. The documented requirement is FULL. Give a CDC stream MINIMAL and you are handing it an update event with holes in it.

At some point in the past, through a parameter change nobody connected to this, the source spent a window running MINIMAL. During that window, every UPDATE that did not touch tenant_id produced a change event that did not mention tenant_id. The key was there. The changed column was there. The tenant was simply not in the message.

A source row with tenant_id 42 gets an UPDATE that only changes status. Because the binlog is set to MINIMAL, the change event carries only the key and the changed column, so tenant_id is absent. DMS reports healthy with zero errors and applies the event faithfully, and the target row ends up with tenant_id NULL.

SHOW VARIABLES LIKE 'binlog_row_image';
-- FULL is what CDC needs. MINIMAL is what quietly breaks it.

Why absolutely nothing errored

This is the lesson hiding inside the incident, and it is bigger than MySQL.

Every layer did its job correctly. MySQL logged exactly what MINIMAL says to log. The binlog was valid. DMS read a valid event and applied exactly what it received. The target accepted a valid write. There was no corrupt packet, no failed job, no exception to catch anywhere in the chain.

The corruption lived in the gap between what was logged and what was needed. And gaps do not raise exceptions. They just quietly produce the wrong answer while every component around them reports success. That is the entire failure mode of data infrastructure in one sentence.

The fix, in three parts

You cannot patch this with a restart, because the damage is already written into rows.

After the fix, the source runs binlog_row_image FULL, the affected target tables are reloaded once from source, and a DMS data validation layer continuously compares source and target row by row, so any future mismatch surfaces as an alert instead of a surprise.

First, stop the bleeding. Set binlog_row_image = FULL on the source so the CDC stream stops receiving events with holes. Every change from this point forward carries the whole row.

Second, repair the damage. The correct tenant_id values only ever existed on the source, so there is nothing to compute or guess. The affected tables have to be reloaded from the source, replacing the corrupted rows with the truth. A targeted full reload of just those tables is the honest fix. Painful, but honest.

Third, make it impossible to miss next time. Turn on DMS data validation, which continuously compares source and target row by row and reports mismatches. If this exact drift happens again, it shows up as an alert, not as someone’s eyebrow three weeks later.

task setting:
  EnableValidation: true
  # DMS compares source and target and flags rows that disagree

Pipeline health is not data health

The whole incident collapses into one distinction that I now treat as a rule.

QuestionPipeline health answersData health answers
Is the job running?YesDoes not care
Any errors thrown?ZeroDoes not care
Is the lag low?YesDoes not care
Do source and target agree?No ideaThis is the only question
Are the values correct?No ideaThis is the only question

Watching only the left column is how you end up confidently green while the data rots. You need something asking the right column too, and it has to be automated, because the manual version of that check is a customer.

What I watch for data now

Pipeline metrics stay, but they are the floor, not the ceiling. The signals that actually catch this class of problem look at the data itself:

source_vs_target_row_count_diff
null_rate on critical columns (tenant_id, foreign keys, money)
validation_mismatch_count
target freshness against an SLO
distribution drift on key columns

The single most valuable one is the null rate on columns that are never supposed to be null. A tenant_id null rate creeping above zero is not a metric, it is a smoke alarm, and it would have caught this in hours instead of weeks.

Final takeaway

Data infrastructure gets treated as a lesser cousin of “real” production, right up until the wrong number reaches a customer, a report, or a regulator. Then everyone remembers that data is production too.

The reason it needs SRE is precisely because it fails so politely. A down service screams. Bad data whispers, and it keeps whispering while every dashboard around it smiles and says everything is fine. So build the thing that checks the data against itself, alert on the values and not just the job, and keep a healthy distrust of the color green.

Because data does not page you. It just goes quietly wrong, and quietly wrong is the most expensive kind of wrong there is.


Share this post:

Previous Post
Least Privilege Is a UX Problem
Next Post
The CNI Already Moves Your Packets. Calico Is for the Rules.