The Rule Was Right, the List Was Wrong

A table-health pack of eighteen metrics derived from metadata, run against forty-three tables in forty-four seconds, and seven alert rules scored against what the lab had actually done to each table. Three of the pack's own queries were wrong before any rule was, and the rule that inferred a streaming table from its cadence fired on six batch tables.

A rule for small files, excess_files > 20 AND median_file_kb < 32768, run against every table the lab had built in the previous six chapters. Then scored against a list of the tables the author knew to be fragmented.

  small_files    fired on 9
                 false positives 3: [stage11.d2r, stage11.d3r, stage14.replay]   missed 0

The first pass appeared to have three false positives. Looking at the files changed the diagnosis: a v2 and a v3 table each had fifty-nine files over thirty partitions at a median of 58 KB. The Flink table had thirty-seven files at 6 KB each. All three were fragmented. The rule was right; the list — written from memory of what each chapter had done — was wrong. The same mistake appeared in the expected results for the delete and manifest rules. Scoring an alert against ground truth is how you find out that the ground truth is the thing that needs work. That is the first finding of a chapter on observability. The metrics are cheap, and the hard part is knowing what they should say.

The health pack below collects eighteen metrics per table using metadata queries plus one request to the catalog. These are the metadata tables introduced in Book 1’s chapter 9 and used here since chapter 7. Seven alert rules turn the measurements into possible work. Their misses matter as much as their successes: a rule should help someone decide what to do, and the wrong diagnosis sends them to the wrong job.

The pack

MetricSourceOwner
snapshots; snapshots since the last data compactionsnapshots, with summary['deleted-data-files'] > 0 to tell a compaction from a manifest rewritech8, ch10
files; partitions; files beyond one per partitiondata_files, partitionsch7, ch8
median file sizedata_files.file_size_in_bytesch8
delete files; delete rows per data filedelete_filesch8, ch11
manifestsmanifestsch9
metadata file size; metadata-log entriesmetadata_log_entries, plus one HEAD on the storech9
referencesrefsch10
last commit; freshnesssnapshots.committed_atch11, ch12
commit cadence, median and p95 gaplag() over snapshots.committed_atch11
written by Flinksummary['flink.job-id'] on the latest snapshotch11
load-table payload and latencyone GET on the catalogch9
-- the four the rules lean on hardest
SELECT count(*) FROM t.snapshots
 WHERE committed_at > coalesce((SELECT max(committed_at) FROM t.snapshots
                                 WHERE operation = 'replace'
                                   AND CAST(summary['deleted-data-files'] AS INT) > 0), timestamp '1970-01-01');
SELECT count(*) - (SELECT count(*) FROM t.partitions) FROM t.data_files;
SELECT coalesce(sum(record_count), 0) / greatest((SELECT count(*) FROM t.data_files), 1) FROM t.delete_files;
SELECT percentile_approx(gap, 0.5) FROM (SELECT unix_timestamp(committed_at)
        - unix_timestamp(lag(committed_at) OVER (ORDER BY committed_at)) AS gap FROM t.snapshots) WHERE gap IS NOT NULL;
  43 tables in 44s (1.0s per table, 18 metrics each)

The pack took a second per table for nineteen small queries, without opening a data file. It still pays for planning: each query reads manifests and metadata, repeating chapter 9’s planning cost nineteen times per table. A large load-table response adds to that cost on every query — the catalog measurement therefore helps account for the pack’s own load as well as the engines’. At this rate, a thousand tables take seventeen minutes on one Spark session. That suits an hourly pass. Freshness needs a per-minute check, so its single query runs separately; the remaining metrics follow the maintenance schedule.

The pack’s own defects, found by running it

Three of the queries were wrong on the first pass, and each one is a class of mistake a health pack makes.

The partition column does not exist on an unpartitioned table. count(DISTINCT partition) over data_files failed on every Flink table and every CDC table — silently. The pack reported their partition count as zero and their excess files as all of them. The partitions metadata table has one row for an unpartitioned table, so counting it is the query that works on both.

A rule that fires on everything is a units bug or a guessed threshold. The load-table payload came back in bytes and was compared against a threshold written in kilobytes, so metadata_bloat fired on all forty-three tables. Fixed, and with the threshold set from the fleet’s own distribution, median 78 KB, ninetieth percentile 160 KB, it fired on exactly the one table with six hundred snapshots.

replace is two different operations. The snapshot log records a data compaction and a manifest rewrite with the same operation name. “Snapshots since the last compaction” counted from whichever came last, and a table whose manifests had just been rewritten looked freshly compacted. The summary tells them apart — a compaction has deleted-data-files, a manifest rewrite does not.

And one blind spot that is not a defect but has to be known: the count of snapshots since compaction is bounded by retention. Chapter 10’s hot-class tables keep two snapshots, so their compaction lag reads two whatever happened, and the six-hundred-commit table read ten after its expiry. On a table with aggressive retention the lag metric measures the retention window — and files beyond one per partition is the metric that measures the table.

The fleet, as the pack saw it

A selection of the forty-three rows, chosen to show each metric doing its job.

Tablesnapshotssince compactionfilespartitionsexcessp50 KBdelete filesdel/filemanifestsmetadata KBload KB
stage12.m_after (ch9, 600 commits)601601600305702001559560
stage12.m_before (ch9, expired)10106013057120071212
stage11.stream (ch8, uncompacted)15015015030120580051151151
stage11.c1 (ch8, the lost race)151151146301162190052152152
stage11.s_binpack (ch8, compacted)1510303002750052153153
stage10.orders_hourly (ch7)10109367202161600101212
stage11.d2 (ch8, v2 deletes)71030300109602,000627777
stage15.orders_replay (ch12)11110347261,182223
stage14.events (ch11, Flink)1051531270041515
stage15.orders_cdc (ch12, rewritten)8011034200111111

Two rows are worth a second look. The compacted s_binpack has thirty files, zero excess, and fifty-two manifests. Chapter 8’s compaction rewrote its data files and never its manifests, and that is the row that made the manifest rule’s ground truth wrong. A compaction leaves every manifest it touched in place with its entries marked deleted, and only chapter 9’s rewrite folds them; the pack sees the difference and the author had forgotten it. And orders_replay has one data file and two delete files holding sixty-one thousand delete rows against it. That is chapter 12’s landing table before its rewrite, and the highest delete ratio in the fleet by a factor of thirty.

Seven rules, scored

Each rule was run against the forty-three rows, and its firings compared with the list of tables the chapters’ records say should fire. The list was corrected where the rule proved it wrong; the misses that remain are the rule’s.

RuleFiredFalse positivesMissedWhat the misses are
small_files: excess > 20 and p50 < 32 MB903three Flink tables with 11–19 tiny files: under the excess threshold, which was set for batch fleets
delete_debt: del/file > 100 or delete files > 10400
compaction_lag: since compaction > 50300after the deleted-data-files fix; before it, the 600-commit table read zero
metadata_bloat: metadata > 256 KB100after the units fix; before it, 43
manifest_sprawl: manifests > 201901one table at exactly 20; a threshold is an edge and something sits on it
stale_stream: cadence median < 30 s and fresh > 5 min1062inferred from cadence: fired on batch tables written in bursts, on bounded jobs that finished, on a table merged twice five seconds apart; missed two tables with one snapshot
stale_flink: last snapshot carries flink.job-id and fresh > 5 min602declared by the writer: missed the two upsert tables whose latest snapshot was a Spark rewrite

The two freshness rules expose a problem that fixing the queries cannot solve. Both try to recognise a streaming table from its recent commits. Their different mistakes show why the pack needs to be told which tables are streams.

A stream is declared, not inferred

Whether a table is supposed to be receiving commits every few seconds is not a property of the table’s metadata. The cadence rule tried to infer it from the gaps between commits and was wrong six times in ten. A batch table loaded in ten quick inserts has a median gap of one second and looks like a stream that stopped. A bounded Flink job that finished looks the same. A table merged twice in one session looks the same. The summary rule looked for the writer’s own mark — the flink.job-id chapter 11 found on every commit a Flink job makes — and had no false positives. It missed the two tables whose latest commit was a Spark maintenance rewrite, which carries no such key. The mark is per snapshot, and the last snapshot is not always the writer’s.

The class of a table is a table property, set when the table is created, and the pack reads it. bookshop.class = stream on the events table, cdc-landing on the orders landing table, batch on the rest. Then the stale rule is class = stream AND freshness > 5 min and it has nothing to infer. The writer’s mark in the summary remains useful as the audit that the declared writer is the actual one, read over the last several snapshots rather than the last.

Symptoms and actions

The seven rules map onto the chapters that own the fix, and one of them does not map onto a table at all.

Rule firesThe actionWhere
small_filesdata-file rewrite, scoped by the partitions still being writtench8
delete_debtdata-file rewrite with delete-file-thresholdch8, ch11
compaction_lagschedule the compaction that is not runningch8
metadata_bloatexpire snapshots; set delete-after-commitch9, ch10
manifest_sprawlrewrite_manifests, after the compaction that left themch9
stale streamnothing on the table: check the jobch11

The stale stream is a symptom. The table is fine; the writer has stopped, and the runbook entry is chapter 11’s, not a maintenance procedure. A dashboard that puts it beside the maintenance rules invites someone to run a compaction on a table whose problem is a dead Flink job. A rule is actionable when its action is on the object it measured. The rest are symptoms, and symptoms belong on the writer’s dashboard — with chapter 11’s checkpoint counts and chapter 12’s convergence time — not on the table’s.

What the pack does not measure

Four operational metrics need sources outside the metadata tables, so they belong in a second pack. Commit success and latency are the writer’s: Flink’s checkpoint counts and durations from chapter 11’s REST calls, and for batch writers the job scheduler’s exit codes. Planning latency is chapter 9’s probe, requests under metadata/ counted at the proxy. Freshness for change data is the checkpoint interval plus the source connector’s lag, and Debezium’s lag is not in Iceberg. Cost per table is chapter 2’s request table applied to the per-table request counts the proxy produces, and chapter 18 assembles it. Each of those has a source; none of them is the table’s metadata, and a pack that only reads metadata has to say so on its dashboard.

The dashboard schema

One row per table per run of the pack, which is the health table itself, stored as an Iceberg table so that its own history is queryable.

CREATE TABLE ops.table_health (
  run_at TIMESTAMP, catalog STRING, tbl STRING, class STRING,
  snapshots INT, since_compaction INT, files INT, partitions INT, excess_files INT,
  median_file_kb DOUBLE, delete_files INT, deletes_per_file DOUBLE, manifests INT,
  refs INT, last_commit TIMESTAMP, freshness_min DOUBLE, gap_p50_s DOUBLE, gap_p95_s DOUBLE,
  metadata_kb DOUBLE, load_kb DOUBLE, load_ms DOUBLE, flink_written BOOLEAN
) USING iceberg PARTITIONED BY (days(run_at));

The rules are views over it, one per row of the scoring table, filtered by class. The false-positive rate of each rule is then a query too: rule firings joined against the maintenance log, over as much history as the health table has. This lab has hours of history and forty-three tables. The rates above are one instant across a fleet — not a week across time. The lab could not yet supply the week of history needed for that longer view.

What to decide

Declare the class at creation, as a table property every rule reads. The batch bursts and maintenance commits above show why recent history alone cannot reliably tell the pack whether a stream has stopped.

Score every rule before it pages anyone, against a list, and expect the list to be wrong first. The list is corrected from the rows; the rule is corrected from the list.

Set thresholds from the fleet’s distribution — never from a guess — and re-derive them when the fleet changes. A rule that fires on everything has a bug, and a threshold is an edge that something will sit on.

Split the dashboard by who acts. Table rules for the maintenance jobs. Writer rules for the job owners. A symptom on the wrong dashboard is a compaction run against a dead writer.

Store the health results in a table. Joining past firings to the maintenance log then lets you query the rules’ accuracy instead of judging each alert in isolation.

What was not run

A week of history: the rates are one pass over forty-three tables. Debezium’s own lag, which is the change-data freshness the pack cannot see. The query log, which is where chapter 7’s predicate skew lives and which no metadata table records. And alert fatigue over time, which is the number that decides whether anyone still reads the dashboard in a month.

Exercises

1. Find your fleet’s thresholds. Run the pack once and compute the median, ninetieth and ninety-ninth percentile of excess_files, deletes_per_file, manifests and metadata_kb across every table. Set each rule’s threshold at the ninetieth percentile and count the firings; then at the ninety-ninth.

Show answer

At the ninetieth percentile a tenth of the fleet fires on every run, which is the pool the maintenance budget can reach. At the ninety-ninth only the outliers fire, which is a page. The two thresholds are two different rules, a work queue and an alarm, and both should exist.

2. Catch the manifest rewrite. On a table that has been compacted, run rewrite_manifests and then the naive “since last replace” query and the corrected one. Then run a compaction and repeat.

Show answer

After the manifest rewrite the naive count resets to zero and the corrected one does not, because the rewrite’s summary has no deleted-data-files. After the compaction both reset. A dashboard on the naive query shows a table as freshly compacted every time its manifests are tidied.

Final thoughts

Every number on the dashboard came from a metadata table and cost a second per table to produce. The only expensive part of the chapter was finding out which numbers to trust. Three of the pack’s queries were wrong, and the fleet said so. The ground truth was wrong three times, and the rules said so. The rule that inferred what a table was for was wrong six times in ten — and the fix was to stop inferring.

A metadata dashboard can still look healthy when the objects it describes are missing. The next chapter starts with that failure, then works through the incidents produced in the lab, keeping the evidence from triage through recovery.

Next: The Count Was Right and the File Was Gone

Comments