<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jennifer Evangelista</title>
    <description>The latest articles on DEV Community by Jennifer Evangelista (@jenniferevangelistade).</description>
    <link>https://dev.to/jenniferevangelistade</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201499%2Fda7c57a6-9225-47b5-8bec-bd61e8b256c6.png</url>
      <title>DEV Community: Jennifer Evangelista</title>
      <link>https://dev.to/jenniferevangelistade</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jenniferevangelistade"/>
    <language>en</language>
    <item>
      <title>Night Guard: an open-model guard that watches out for kids in game chats, so tired parents don't have to read every message</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Sat, 24 Oct 2026 03:05:14 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/night-guard-an-open-model-guard-that-watches-out-for-kids-in-game-chats-so-tired-parents-dont-3fb6</link>
      <guid>https://dev.to/jenniferevangelistade/night-guard-an-open-model-guard-that-watches-out-for-kids-in-game-chats-so-tired-parents-dont-3fb6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest 2026 Week 3 Challenge: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Looking for the prize categories? One table at the end (Prize Categories) lists each one, what I built for it and which section shows it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Parents can't read every chat their kids have in Roblox, Discord or TikTok. Meanwhile the US National Center for Missing &amp;amp; Exploited Children received &lt;strong&gt;more than 50,000 reports of financial sextortion in 2025, about 137 a day&lt;/strong&gt; (&lt;a href="https://us.missingkids.org/blog/2026/ncmec-releases-new-sextortion-data-2025" rel="noopener noreferrer"&gt;NCMEC, 2026&lt;/a&gt;). And it can be fast: in a Swansea University study of chats between groomers and researchers posing as children, the time from first contact to the first sexual request ranged from &lt;strong&gt;18 minutes to 82 hours&lt;/strong&gt; (&lt;a href="https://www.irishexaminer.com/breakingnews/world/paedophiles-can-groom-child-online-in-less-than-20-minutes-753583.html" rel="noopener noreferrer"&gt;Irish Examiner, 2016&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Night Guard&lt;/strong&gt; is a small open-model guard designed to reduce that review effort. It reads messages &lt;strong&gt;on the family's own computer&lt;/strong&gt; and highlights possible pressure tactics: someone asking a kid to keep a secret, to move to a private app, to send a photo, or threatening to share one.&lt;/p&gt;

&lt;p&gt;It talks to two different people in two different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;To the kid, it's a friend that turns them into a hero.&lt;/strong&gt; It warns in plain words and says "danger" when it is one: &lt;em&gt;"DANGER: someone asked you to hide this from your family. It's like a stranger on the street saying 'come with me, but don't tell your mom'. You don't go, and you tell an adult."&lt;/em&gt; A calm voice can read it aloud. The kid earns practice progress from completed exercises, while previously practiced explanations can be reopened. Repeated exposure to warnings does not certify learning, and protection stays on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;To the parents, it's the Night Guard 🌙.&lt;/strong&gt; They get the risk level, the app and the flagged phrase (never the whole chat), why it matters, and what to do right now. Optionally, a push on their phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The intended lazy payoff is &lt;strong&gt;less repetitive review for parents and shorter, useful explanations for kids&lt;/strong&gt;. The guard highlights events for the family and repeats fewer lessons as the child practices. Time saved and real-world protection have not been measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it is not
&lt;/h3&gt;

&lt;p&gt;It's not a bad-words filter and not sentiment analysis. Pressure can appear in friendly wording ("you're so mature for your age", "free Robux for you"). Night Guard uses an open model to read the &lt;strong&gt;messages provided to each check&lt;/strong&gt; and match it against 14 app-defined tactic labels. Examples of these behaviors are documented in &lt;a href="https://www.publicsafety.gc.ca/cnt/cntrng-crm/chld-sxl-xplttn-ntrnt/wbnr/wbnr-2025-en.aspx" rel="noopener noreferrer"&gt;Public Safety Canada's 2025 guidance on gaming platforms&lt;/a&gt;, &lt;a href="https://www.missingkids.org/theissues/sextortion" rel="noopener noreferrer"&gt;NCMEC's sextortion guidance&lt;/a&gt;, and &lt;a href="https://info.thorn.org/hubfs/Research/2022_Online_Grooming_Report.pdf" rel="noopener noreferrer"&gt;Thorn's Online Grooming Report (2022)&lt;/a&gt;. The live overlay supplies the &lt;strong&gt;12 most recent messages&lt;/strong&gt;, not the full chat history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The short film (2:30), narrated by Snoozy, the lazy night watchman:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/DFyBdn8d7xU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://night-guard-demo.onrender.com" rel="noopener noreferrer"&gt;https://night-guard-demo.onrender.com&lt;/a&gt; (fictional examples only; the free instance can take ~50 s to wake up, a check takes about 7 to 15 s online, and it accepts a limited number of checks per hour so it stays free for everyone)&lt;/p&gt;

&lt;p&gt;Start with &lt;strong&gt;① Watch the guard act on its own&lt;/strong&gt;: a fictional game chat where a stranger writes and Night Guard warns the kid by itself, without anyone pasting anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkisk4g6nkprv74d6ha4q.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkisk4g6nkprv74d6ha4q.jpg" alt="The live game chat: a stranger asks for secrecy and a selfie, and the guard warns the kid on its own" width="612" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;③ See what parents get&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxn4crk71k7nqwuni8kh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxn4crk71k7nqwuni8kh.jpg" alt="The parents' view: risk level, app, flagged phrase, why it matters and what to do now" width="799" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every feature, recorded live on the local app (about 3 minutes):&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/__upiNIIlt0" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The online demo runs the &lt;strong&gt;Tinker fine-tuned model&lt;/strong&gt; in the cloud so judges can try it without installing anything. The real Night Guard runs on the family's computer with &lt;strong&gt;Gemma&lt;/strong&gt;, where chat analysis stays on the device. Optional external link inspection is a separate action described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/jenniferevangelista-de" rel="noopener noreferrer"&gt;
        jenniferevangelista-de
      &lt;/a&gt; / &lt;a href="https://github.com/jenniferevangelista-de/night-guard" rel="noopener noreferrer"&gt;
        night-guard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Helps kids understand what happened in an online chat and choose how to ask for help. Runs locally with Gemma.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://github.com/jenniferevangelista-de/night-guard/blob/main/docs/VIDEOS.md" rel="noopener noreferrer"&gt;Video production notes and credits&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The current full suite has &lt;strong&gt;94 passing tests&lt;/strong&gt; (&lt;code&gt;pytest tests&lt;/code&gt;), including the demo's request limits, regression cases for every bug found in testing and simulated tests for the link inspector and Backboard client. Separate live Auth0, Browserbase and SerpApi checks are described below; automated tests do not replace those integration checks. &lt;a href="https://github.com/jenniferevangelista-de/night-guard/blob/main/docs/validation/2026-10-25.md" rel="noopener noreferrer"&gt;The offline test run and validation of all 30 audio assets&lt;/a&gt; are recorded in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuhfgavpbcf5wyflroe1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuhfgavpbcf5wyflroe1.png" alt="Night Guard architecture: chats are analyzed on the family's computer; only a phone alert (risk type), an optional link check (URL only) and agent traces (labels only) leave it" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Auth0 sign-in for the local Family controls is optional and only applies when configured; the public demo does not use Auth0.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The detector: Gemma 4, on the kid's computer
&lt;/h3&gt;

&lt;p&gt;The core is &lt;code&gt;gemma4:e4b&lt;/code&gt; running locally through &lt;strong&gt;Ollama&lt;/strong&gt; on a laptop GPU (RTX 4050, 6 GB). Each check sends the recent messages with a structured JSON schema and gets back the signals it found, the message index and the exact quote.&lt;/p&gt;

&lt;p&gt;I don't trust the model blindly. Every answer goes through checks before a kid or parent sees it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An unreadable answer is never "all clear".&lt;/strong&gt; If the model is down or answers in a broken format, the kid sees &lt;em&gt;"Couldn't analyze this chat. This is NOT an all-clear"&lt;/em&gt;, never a green light. The evaluations also count a failed analysis as an error, never as a catch or as a correct "safe".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence is checked against the chat.&lt;/strong&gt; The quote must exist in a message from the contact; a paraphrased quote is replaced by the real message; the kid's own messages are never flagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two narrow tactics need their own words in the chat.&lt;/strong&gt; "Came back with a new account" only counts if the chat mentions being blocked or ignored, and "you're so mature" flattery only counts with maturity or age words. Before this, a classmate who lost his password and a compliment on a Minecraft build were flagged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Results on a fictional test set of 70 chats (36 risky, 34 safe), written by me for testing:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;First version (60 chats, 11 signals)&lt;/th&gt;
&lt;th&gt;Now (70 chats, 14 signals)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid analyses (an error never counts as a catch)&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70/70&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risky chats flagged&lt;/td&gt;
&lt;td&gt;30/30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36/36&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False alarms on safe chats&lt;/td&gt;
&lt;td&gt;4/30 (13%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3/34 (9%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal precision / recall&lt;/td&gt;
&lt;td&gt;89% / 87%&lt;/td&gt;
&lt;td&gt;88% / 87%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg time per check&lt;/td&gt;
&lt;td&gt;3.8 s (historical run)&lt;/td&gt;
&lt;td&gt;3.41 s (local run; the result file is not versioned)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three remaining false alarms are on purpose hard cases: an in-game "secret" about a leaked Halloween event, a classmate saying "see you at school", and "how old r u now" on a birthday. Honesty note: two newer hard cases were fixed &lt;strong&gt;after&lt;/strong&gt; I saw them fail, so they're no longer a blind test. The 60-chat and 70-chat columns are different development evaluations, and the 70 cases have already informed changes. The 36/36 result applies to these fictional patterns, not an independent holdout or real-world protection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proactive: it doesn't wait for a kid to paste anything
&lt;/h3&gt;

&lt;p&gt;A "paste your chat here" tool only helps if the kid thinks to use it, and pressure can escalate within a single chat session. So Night Guard also watches as messages arrive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Screen guard with Gemma's vision.&lt;/strong&gt; Kids chat inside &lt;em&gt;installed&lt;/em&gt; apps (the Roblox app, the Discord app, Minecraft). Every few seconds Night Guard checks which &lt;strong&gt;program&lt;/strong&gt; owns the window in front (by its process, not its title, so a browser tab or a document called "Roblox" is ignored). Only if it is one of those apps does it take a screenshot &lt;strong&gt;in memory&lt;/strong&gt;, Gemma 4 reads the chat from the image locally (~7 s warm), and the text goes to the same detector. The image is never saved and never leaves the computer. Banking, email or school windows are not on the list, so they are never captured. If the window changes before the capture, nothing is read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Chrome extension&lt;/strong&gt; for web chats (Discord web and WhatsApp Web, experimental), which only talks to the local server and refuses any other address.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;"🛡️ Guard on"&lt;/strong&gt; badge identifies the active guard, and it turns into &lt;em&gt;"Guard paused: can't analyze right now"&lt;/em&gt; when analysis fails; silence is not proof of safety. Kids know it's there. In a study of 736 reviews attributed to children aged 8–19, 76% gave safety apps one star (&lt;a href="https://www.cs.ucf.edu/~jjl/pubs/pn1838-ghoshA.pdf" rel="noopener noreferrer"&gt;Ghosh et al.&lt;/a&gt;). That finding motivates transparent design; it does not establish that covert monitoring causes harm to family relationships.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fine-tuning with Tinker: teaching a model to speak the app's language
&lt;/h3&gt;

&lt;p&gt;Tinker doesn't serve Gemma, so for the cloud demo I fine-tuned &lt;strong&gt;Qwen3-8B&lt;/strong&gt; with a LoRA (rank 16) on &lt;strong&gt;Thinking Machines' Tinker&lt;/strong&gt;, using fictional training chats that share no sentence with the test set. Two runs: v1 (11 tactics, 800 chats) and v2 (14 tactics, 1,000 chats, ~1.23M tokens), all compared on the same 70 test chats:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Qwen3-8B base&lt;/th&gt;
&lt;th&gt;LoRA v1&lt;/th&gt;
&lt;th&gt;LoRA v2 (online demo)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Answers that follow the app's full schema (tactic id, message index, quote, summary)&lt;/td&gt;
&lt;td&gt;11 / 70&lt;/td&gt;
&lt;td&gt;70 / 70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70 / 70&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risky chats flagged (unusable answers never count as catches)&lt;/td&gt;
&lt;td&gt;0 / 36 (none of the 36 was usable)&lt;/td&gt;
&lt;td&gt;36 / 36&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36 / 36&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False alarms on safe chats&lt;/td&gt;
&lt;td&gt;0 / 11 valid (23 invalid)&lt;/td&gt;
&lt;td&gt;3 / 34&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 / 34&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal precision / recall&lt;/td&gt;
&lt;td&gt;n/a (no valid risky answer)&lt;/td&gt;
&lt;td&gt;0.84 / 0.79&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.88 / 0.83&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg time per check&lt;/td&gt;
&lt;td&gt;3.0 s&lt;/td&gt;
&lt;td&gt;5.0 s&lt;/td&gt;
&lt;td&gt;4.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Latency values are the aggregate figures stored in the current result JSON files. Older run logs report different timings; per-case timings were not retained, so these averages cannot be independently recomputed.&lt;/p&gt;

&lt;p&gt;Strict format conformity (&lt;code&gt;valid_schema&lt;/code&gt;) is reported separately from usable coverage. The common postprocessor retains valid evidence in a partial answer; an unusable answer is an error, never a catch or a correct safe result. These saved Tinker runs contain no partial answers, so strict-schema and usable-coverage totals coincide: 11/70 for base and 70/70 for each LoRA. The base model often renames fields (&lt;code&gt;signal_id&lt;/code&gt; instead of &lt;code&gt;id&lt;/code&gt;). Read leniently, it flagged 35 of the 35 risky chats it answered readably, so it often &lt;em&gt;sees&lt;/em&gt; the pattern; its unrecognized fields made those answers unusable under the original schema. Fine-tuning produced schema-valid answers on all 70 development chats and found more signals, including the three newer tactics in v2. The online demo runs v2. Two honest limits: the false-alarm gap between v1 and v2 (3 vs 2 of 34) is a single chat, and the LoRA is not faster than the base model; this is a measured format improvement on the evaluated cases, not a guarantee for future inputs. Every raw answer is saved per case in the repo, so anyone can recount.&lt;/p&gt;

&lt;h3&gt;
  
  
  Temporal: an evaluation that survives a crashing laptop
&lt;/h3&gt;

&lt;p&gt;Evaluating a local model on a laptop is fragile. The evaluation runs as a &lt;strong&gt;Temporal&lt;/strong&gt; workflow where each conversation is an activity with automatic retries. In a 60-conversation crash test, Ollama was stopped during the run. The saved &lt;code&gt;eval/temporal_report.json&lt;/code&gt; records &lt;code&gt;s12&lt;/code&gt; completing on &lt;strong&gt;attempt 5&lt;/strong&gt;. The failed activity was retried when the model returned; completed activities remained in the workflow history.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70ngeesrrf9c7v5fvbyt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70ngeesrrf9c7v5fvbyt.jpg" alt="Temporal workflow completed after the crash test" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ElevenLabs: a calm voice for kids who don't read fast yet
&lt;/h3&gt;

&lt;p&gt;Younger kids and kids with dyslexia may not read a warning in time. Every child warning (14 tactics × English and Portuguese) was voiced once with &lt;strong&gt;ElevenLabs&lt;/strong&gt; (Sarah voice, &lt;code&gt;eleven_multilingual_v2&lt;/code&gt;) and shipped as audio files. In the live game demo and in the screen guard popup, the guard reads the most serious new warning aloud, and it speaks again if the stranger insists with a new message. Only the fixed warning texts were ever sent to ElevenLabs. No chat, ever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sentry: tracing the agent without reading the chats
&lt;/h3&gt;

&lt;p&gt;When configured, &lt;strong&gt;Sentry&lt;/strong&gt; traces each check as an agent run (&lt;code&gt;night-guard.check&lt;/code&gt;) with a model call inside (&lt;code&gt;gen_ai.chat&lt;/code&gt;) and the evidence step after it. That's how I saw that a cold Gemma call takes up to 8 s and a warm one about 4.5 s on this laptop. The traces carry only numbers and labels: model, backend, latency, number of messages, severity and tactic ids. Never a message, a name or a quote: &lt;code&gt;send_default_pii&lt;/code&gt; is off, local variables are not captured, and every event goes through an &lt;strong&gt;allowlist&lt;/strong&gt;: only fields known to hold numbers and labels are kept, everything else (request bodies, breadcrumbs, exception text, any new field) is dropped. A test pushes a fake event full of a marker string and checks the marker never survives. The UI's status polling is sampled out so the agent runs stand out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsa9e8aqt5bvynrsp7iw1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsa9e8aqt5bvynrsp7iw1.jpg" alt="Sentry traces: each night-guard.check with its gen_ai.chat model call" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Render: one click for the judges
&lt;/h3&gt;

&lt;p&gt;The public demo runs on &lt;strong&gt;Render&lt;/strong&gt; (Blueprint &lt;code&gt;render.yaml&lt;/code&gt;, free instance), with the Tinker fine-tuned model as its AI backend. The app does not persist chats on the demo server: family rules and alerts stay in each visitor's own browser, and the screen says to use fictional examples only. This describes application behavior, not the retention policies of Render or the model provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browserbase + SerpApi: check a link without opening it on the child's computer
&lt;/h3&gt;

&lt;p&gt;When a conversation contains a link, &lt;strong&gt;"Check without opening"&lt;/strong&gt; uses Browserbase to open it in an isolated cloud&lt;br&gt;
browser. The inspector reads the final destination, checks for password fields and returns a screenshot.&lt;br&gt;
SerpApi separately searches for reports about the domain. The selected URL goes to Browserbase and the domain&lt;br&gt;
to SerpApi; the surrounding conversation is never sent. A URL can itself contain personal information.&lt;br&gt;
Each provider is optional, and the result identifies the checks that ran. Missing reports never mean "safe".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live local evidence, October 23, 2026:&lt;/strong&gt; one Browserbase session opened the fictional BlockQuest "free gems"&lt;br&gt;
page (session creation HTTP 201, page HTTP 200). It detected the password field, captured a screenshot and&lt;br&gt;
returned the Portuguese warning &lt;strong&gt;"Não abra esse link"&lt;/strong&gt; ("Don't open this link") in 12.68 seconds. Session release&lt;br&gt;
returned HTTP 200. A separate, single SerpApi query for &lt;code&gt;night-guard-demo.onrender.com&lt;/code&gt; returned HTTP 200 and six&lt;br&gt;
search results, with none matching the report heuristic. The fictional page submits no credentials.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj2u640k7vzleczcj6seb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj2u640k7vzleczcj6seb.jpg" alt="The deployed inspector's Browserbase screenshot of the complete fictional BlockQuest login form" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployed Render endpoint also passed a live check after the viewport correction:&lt;/strong&gt; &lt;code&gt;POST /api/link/check&lt;/code&gt;&lt;br&gt;
returned HTTP 200, identified both Browserbase and SerpApi as checked, detected the password field and returned&lt;br&gt;
the same warning in 8.90 s. Its domain search returned six results, with none matching the report heuristic.&lt;br&gt;
The returned screenshot was visually verified to show the complete fictional login form; that public-endpoint&lt;br&gt;
screenshot is shown above.&lt;/p&gt;

&lt;p&gt;These checks establish provider connectivity and the observed password detection on one fictional example,&lt;br&gt;
not scam-detection accuracy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optional Auth0 sign-in for local Family controls
&lt;/h3&gt;

&lt;p&gt;When configured, the local Ollama app uses &lt;strong&gt;Auth0&lt;/strong&gt; to sign parents in before they can read family alerts, change rules or stop&lt;br&gt;
the guard. The email must be verified and present in the family's &lt;code&gt;PARENT_EMAILS&lt;/code&gt; list. It requires Auth0&lt;br&gt;
application settings and a session secret; without configuration, the local Family view has no login gate.&lt;br&gt;
The public demo does not use Auth0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live local evidence:&lt;/strong&gt; on a separate test server at port 8766, the alerts endpoint returned HTTP 401 before&lt;br&gt;
login, and the login route redirected with HTTP 302. A real Google sign-in with a verified, allowlisted parent&lt;br&gt;
completed the callback and the Family view displayed the signed-in parent. Logout returned home and restored&lt;br&gt;
the locked Parents view and sign-in button. Rejection of unverified or unlisted accounts is unit-tested; a real&lt;br&gt;
rejected-account attempt has not been performed. Local servers need restarting after &lt;code&gt;.env&lt;/code&gt; changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backboard: compare open-weight models through one API key
&lt;/h3&gt;

&lt;p&gt;On October 23, 2026, &lt;strong&gt;Backboard&lt;/strong&gt; served three complete evaluations: Gemma 3 27B, Llama 3.3 70B and Mistral&lt;br&gt;
Small 3.1 24B, each on the same 70 fictional development conversations (36 risky, 34 safe). All three received&lt;br&gt;
the shared classification &lt;code&gt;SYSTEM&lt;/code&gt; and the canonical app &lt;code&gt;SCHEMA&lt;/code&gt; explicitly in the user prompt, with JSON mode,&lt;br&gt;
memory off, a fresh conversation per case and unchanged app postprocessing/scoring. An initial smoke returned&lt;br&gt;
&lt;code&gt;{}&lt;/code&gt; when only JSON mode was requested; adding the same format schema for every model fixed that missing&lt;br&gt;
instruction. No gold labels were added to prompts. This is a common Backboard adapter, not the identical&lt;br&gt;
prompt/decoding setup used by the older Tinker baseline or schema-constrained Ollama.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Strict schema&lt;/th&gt;
&lt;th&gt;Usable coverage&lt;/th&gt;
&lt;th&gt;Risky chats flagged&lt;/th&gt;
&lt;th&gt;False alarms on valid safe cases&lt;/th&gt;
&lt;th&gt;Signal precision&lt;/th&gt;
&lt;th&gt;Signal recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 27B&lt;/td&gt;
&lt;td&gt;68/70&lt;/td&gt;
&lt;td&gt;69/70&lt;/td&gt;
&lt;td&gt;36/36&lt;/td&gt;
&lt;td&gt;3/33 (1 error)&lt;/td&gt;
&lt;td&gt;0.938&lt;/td&gt;
&lt;td&gt;0.849&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 3.3 70B&lt;/td&gt;
&lt;td&gt;69/70&lt;/td&gt;
&lt;td&gt;69/70&lt;/td&gt;
&lt;td&gt;36/36&lt;/td&gt;
&lt;td&gt;3/33 (1 error)&lt;/td&gt;
&lt;td&gt;0.912&lt;/td&gt;
&lt;td&gt;0.981&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral Small 3.1 24B&lt;/td&gt;
&lt;td&gt;70/70&lt;/td&gt;
&lt;td&gt;70/70&lt;/td&gt;
&lt;td&gt;36/36&lt;/td&gt;
&lt;td&gt;3/34&lt;/td&gt;
&lt;td&gt;0.852&lt;/td&gt;
&lt;td&gt;0.981&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen 2.5 72B&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Unavailable in live smoke&lt;/td&gt;
&lt;td&gt;No full result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strict-schema validity and usable coverage are separate. The shared scorer accepts a &lt;strong&gt;partial&lt;/strong&gt; analysis when&lt;br&gt;
postprocessing retains valid evidence; an unusable analysis is an error, never a catch or a correct safe answer.&lt;br&gt;
Gemma's &lt;code&gt;r06&lt;/code&gt; contained two valid personal-information items and one malformed empty item: the empty item was&lt;br&gt;
discarded and the partial analysis remained usable. Its safe case &lt;code&gt;s33&lt;/code&gt; was unusable. Llama had one unusable safe&lt;br&gt;
case. These failures remain in the denominators and the saved raw answers; nothing was retried to improve scores.&lt;/p&gt;

&lt;p&gt;The practical difference here is format reliability and signal precision/recall, not a demonstrated safety&lt;br&gt;
advantage: all three flagged the 36 risky development cases and raised three false alarms. Mistral followed&lt;br&gt;
the strict schema on every case; Gemma had higher signal precision but lower recall. This is a single run on&lt;br&gt;
reused development cases, not a new blind holdout or proof of protection for real children. Request timings&lt;br&gt;
include shared client slots and waiting, so they are not compared with local Gemma or Tinker latency.&lt;/p&gt;

&lt;p&gt;Qwen 2.5 72B's smoke request hit a &lt;strong&gt;120.65-second read timeout&lt;/strong&gt;. An already-started automatic retry was canceled&lt;br&gt;
locally when the other models were moved to a shared four-request limit. Both Qwen requests appear in billing,&lt;br&gt;
but no answer was observed locally and no 70-case Qwen result is claimed. The model was not silently replaced.&lt;/p&gt;

&lt;p&gt;Backboard's usage API recorded &lt;strong&gt;USD 0.034062 across 216 events&lt;/strong&gt;, including 210 scored calls, three successful&lt;br&gt;
smokes, the initial format-debugging call and two Qwen requests. A conservative token-price/reservation budget&lt;br&gt;
tracked USD 0.065078 against the internal USD 1 cap; that estimate is separate from actual billing. Only&lt;br&gt;
fictional conversations were sent. The &lt;a href="https://github.com/jenniferevangelista-de/night-guard/blob/main/train/backboard_summary.md" rel="noopener noreferrer"&gt;raw results and summary&lt;/a&gt;&lt;br&gt;
preserve per-case outputs, errors, coverage and signal metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the family gets
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Family rules:&lt;/strong&gt; for each tactic, "Tell family" or "Child decides". Photo requests, meeting in person, password requests, secrecy, coming back after a block and sextortion go to the family by default. The kid is always told when that happens: &lt;em&gt;"An alert was recorded for your family. Tell a trusted adult; this is not a delivery confirmation. You're not in trouble."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gap no platform covers:&lt;/strong&gt; Instagram supervision, Discord Family Center, TikTok Family Pairing and Roblox parental controls each protect their own app. The classic path starts in a game and moves to a private app. Night Guard points out a possible match: &lt;em&gt;"Same name seen in more than one app (possibly the same person): Alex_17 (Roblox → Instagram)"&lt;/em&gt;. It's a hint to check, not proof, because names can repeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to do now&lt;/strong&gt;, by severity: talk calmly and without punishment (kids who get in trouble stop telling), keep screenshots, block and report, and for image requests, threats or money demands, report to SaferNet Brasil or the NCMEC CyberTipline and the police. Don't pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone alerts&lt;/strong&gt; through ntfy (free, no account) with only the risk level, the app and the kind of risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Tell someone":&lt;/strong&gt; the kid picks what to share and gets a ready message for Mom, Dad or a teacher. It is assembled deterministically from their own words and the flagged quotes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limits and next step
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phones are the next milestone.&lt;/strong&gt; Today Night Guard protects a Windows computer. On &lt;strong&gt;Android&lt;/strong&gt;, the plan is an app that reads message notifications (the permission parental tools use there) and runs the detector on-device. On &lt;strong&gt;iPhone&lt;/strong&gt;, Apple doesn't let any app read other apps' messages, so the realistic path is Apple's own Communication Safety.&lt;/li&gt;
&lt;li&gt;It's a learning and support tool, not a guarantee of safety. It can miss things and raise false alarms. It doesn't replace trusted adults or platform reporting.&lt;/li&gt;
&lt;li&gt;All test data is fictional. No real children's conversations were used anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Child safety is the clearest case for open models I know. A guard that reads a kid's private chats should &lt;strong&gt;run on the family's own computer&lt;/strong&gt;, be &lt;strong&gt;inspectable&lt;/strong&gt;, and be &lt;strong&gt;free&lt;/strong&gt;. For comparison, &lt;a href="https://support.bark.us/en/articles/13460996-pricing" rel="noopener noreferrer"&gt;Bark Premium&lt;/a&gt; lists US$14 a month before tax. Platforms protect only their own walls. Roblox &lt;a href="https://about.roblox.com/newsroom/2025/08/open-sourcing-roblox-sentinel-preemptive-risk-detection" rel="noopener noreferrer"&gt;open-sourced Sentinel in 2025&lt;/a&gt; and &lt;a href="https://about.roblox.com/newsroom/2026/08/roblox-open-source-safety-models-roost" rel="noopener noreferrer"&gt;announced an update and ROOST contribution in 2026&lt;/a&gt;, which is useful for platforms; Night Guard is the other half: an open, local tool that sits with the family and works across apps.&lt;/p&gt;

&lt;p&gt;Open weights made every part of this possible: Gemma reading text and screenshots on a laptop GPU, a Qwen LoRA fine-tuned in an afternoon, and anyone able to audit exactly which 14 tactics trigger a warning and what words a kid will see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parents&lt;/strong&gt; can review flagged events with suggested next steps, and stay the ones who talk and decide. Reduced review effort is an intended benefit, not a measured result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kids&lt;/strong&gt; get short explanations and practice choices, and can ask an adult for help. Progress in the app does not establish readiness for future situations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guard&lt;/strong&gt; can repeat fewer explanations after completed practice, while leaving every explanation available to reopen. Completing an exercise does not prove future safety and never switches protection off.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Each row: what the category asks for, what I built, and the section that shows it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What it asks&lt;/th&gt;
&lt;th&gt;What I built&lt;/th&gt;
&lt;th&gt;Where to see it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemma&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Use Gemma: run it locally, fine-tune it or serve it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemma4:e4b&lt;/code&gt; on the family's computer reads the chat and, through screenshots, the game window&lt;/td&gt;
&lt;td&gt;The detector: Gemma 4; Proactive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tinker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fine-tune a model and show a clear improvement over a baseline&lt;/td&gt;
&lt;td&gt;Qwen3-8B LoRA: 70/70 answers in the app's schema, against 11/70 for the base model&lt;/td&gt;
&lt;td&gt;Fine-tuning with Tinker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Use Render as the AI runtime or to host the front end&lt;/td&gt;
&lt;td&gt;The public demo runs the fine-tuned model, deployed from a Blueprint&lt;/td&gt;
&lt;td&gt;Render: one click for the judges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Temporal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Make an agent durable: survive failures and retry&lt;/td&gt;
&lt;td&gt;The evaluation runs as a workflow; stopping the model mid-run lost nothing&lt;/td&gt;
&lt;td&gt;Temporal: an evaluation that survives a crashing laptop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Give an open-source agent a voice&lt;/td&gt;
&lt;td&gt;Spoken child warnings in English and Portuguese, shipped as audio files&lt;/td&gt;
&lt;td&gt;ElevenLabs: a calm voice for kids&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentry Agent Tracing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Show the agent's work with traces or screenshots&lt;/td&gt;
&lt;td&gt;Agent runs and model calls traced, with an allowlist so no chat text is sent&lt;/td&gt;
&lt;td&gt;Sentry: tracing the agent without reading the chats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Browserbase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Give an agent a real browser&lt;/td&gt;
&lt;td&gt;The link inspector opens a link in an isolated cloud browser (Playwright over CDP, not Stagehand) and finds password fields&lt;/td&gt;
&lt;td&gt;Browserbase + SerpApi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SerpApi&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Give an agent live web search&lt;/td&gt;
&lt;td&gt;The same inspector searches reports about the link's domain; no reports is never read as "safe"&lt;/td&gt;
&lt;td&gt;Browserbase + SerpApi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth0 for AI Agents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sign users in to an agent&lt;/td&gt;
&lt;td&gt;Verified, allowlisted Google login for the Family controls. Local app only, not the public demo&lt;/td&gt;
&lt;td&gt;Optional Auth0 sign-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Compare open-weight models through one API key&lt;/td&gt;
&lt;td&gt;Three models on the same 70 fictional chats through one key. A Qwen timeout is disclosed&lt;/td&gt;
&lt;td&gt;Backboard: compare open-weight models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overall prize&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Writing, theme, creativity, technical execution&lt;/td&gt;
&lt;td&gt;A friend for the kid, a night guard for the parents; a failure is shown as "couldn't analyze", never "all clear"&lt;/td&gt;
&lt;td&gt;The whole post&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These entries describe the implemented uses and their observed evidence. Final category eligibility is for&lt;br&gt;
the judges under the &lt;a href="https://dev.to/challenges/hf26"&gt;challenge criteria&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your pipeline ran green ✅ and shipped last week’s data anyway</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:12:57 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/your-pipeline-ran-green-and-shipped-last-weeks-data-anyway-1il3</link>
      <guid>https://dev.to/jenniferevangelistade/your-pipeline-ran-green-and-shipped-last-weeks-data-anyway-1il3</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcckbg3n0vj3wji83qju.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcckbg3n0vj3wji83qju.png" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Image generated with AI&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Inspired by the “Is this a pigeon?” meme. The dates and scenario are illustrative.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your backfill finished at 07:03 this morning. It ran cleanly. Every dependency was green. But it was processing last Thursday’s data, and your gate just let it through as today’s load.&lt;/p&gt;

&lt;p&gt;A successful backfill can finish this morning and still belong to last week’s reporting period. If the downstream gate checks only whether its dependencies ran today, the wrong load can pass.&lt;/p&gt;

&lt;p&gt;I would make business date an explicit input and retain the exact upstream run IDs that authorized the output. The small SQLite lab accompanying this article demonstrates that contract, including a retry after publication and two concurrent publishers.&lt;/p&gt;
&lt;h3&gt;
  
  
  The clock answers a narrower question
&lt;/h3&gt;

&lt;p&gt;Imagine two independently scheduled dimension jobs and a daily sales load. The sales load needs both dimensions for October 23.&lt;/p&gt;

&lt;p&gt;At 07:00 UTC on October 23, both jobs complete successfully. A check based on today’s start time sees two green runs and opens the gate. But those runs were backfills for October 16. The current reporting period is still missing.&lt;/p&gt;

&lt;p&gt;The opposite case is just as useful: a valid October 23 run can start before midnight and complete later. A midnight cutoff can exclude work the consumer actually needs.&lt;/p&gt;

&lt;p&gt;In the Databricks Jobs API, start_time_from filters runs by when they started, which says nothing about the business period the code processed. The API also separates completed runs from successful ones. Both are good inputs for a collector, but neither tells you a run covered the right day.&lt;/p&gt;

&lt;p&gt;The fix is not a smarter clock. It is an explicit record of which business date each run actually processed, and a gate that checks that record instead of the wall clock.&lt;/p&gt;
&lt;h3&gt;
  
  
  Write down which attempt counts
&lt;/h3&gt;

&lt;p&gt;My example records job ID, business date, attempt ordinal, run ID, state and a timezone-aware start timestamp. It checks the exact required job IDs against the requested date.&lt;/p&gt;

&lt;p&gt;The attempt ordinal is assigned by the producer for each job/date pair. It’s kept separate from run ID and from when the observation arrived. A delayed message about attempt one must not overwrite the outcome of attempt two.&lt;/p&gt;

&lt;p&gt;I chose a conservative rule: the greatest recorded attempt wins. If attempt 1 succeeds but attempt 2 fails or is still running, the gate blocks: the earlier success is invisible until the newer attempt resolves. Only when the most recent attempt succeeds does the date become ready. Another system might accept the first success instead. That’s a different contract, with its own tests.&lt;/p&gt;

&lt;p&gt;A running observation may transition to one terminal result. Replaying that result is harmless. Changing an already terminal result is rejected; a repair must carry a new attempt ordinal.&lt;/p&gt;

&lt;p&gt;This policy still depends on the collector. If the gate has not learned that a newer attempt exists, it cannot block on that invisible attempt. In a deployment, I would define who publishes authoritative observations and how missing or delayed updates are detected.&lt;/p&gt;
&lt;h3&gt;
  
  
  An executable failure, then a valid release
&lt;/h3&gt;

&lt;p&gt;The lab uses two synthetic dependencies and a synthetic total of 17,500 integer cents. Its demo first records successful October 16 backfills with October 23 start timestamps. Checking October 23 returns false. Recording successful October 23 attempts makes that date ready.&lt;/p&gt;

&lt;p&gt;Run these commands from the repository root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; python/business_date_gate &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'test_*.py'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
python python/business_date_gate/gate.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Backfill completed today: False
Required business date ready: True
First publication: True
Same publication retried: False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last False is the retry being ignored: the existing publication stays and nothing gets written twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put the output and its evidence in one transaction
&lt;/h3&gt;

&lt;p&gt;A readiness check alone does not prevent two workers from publishing the same date. In this local example, the publication key is the consumer name plus business date.&lt;/p&gt;

&lt;p&gt;The code opens a SQLite BEGIN IMMEDIATE transaction, reads readiness, writes the target total and stores the successful upstream run IDs. Both inserts commit together. A repeated call with the same key, dependency set and amount returns without inserting another row. A different amount or dependency set is rejected so a correction cannot silently masquerade as a retry.&lt;/p&gt;

&lt;p&gt;After publication, an upstream failure does not erase the original evidence. Replaying the already committed result remains a no-op. Rebuilding or revoking that result would require an explicit repair workflow.&lt;/p&gt;

&lt;p&gt;The failure test matters here. A SQLite trigger rejects the evidence insert on purpose, right after the target insert. The transaction rolls back both writes. Removing the trigger and retrying succeeds. A separate test launches two publishers on independent connections to the same temporary database; only one creates the publication.&lt;/p&gt;

&lt;p&gt;All 25 new tests passed locally with Python 3.12 and SQLite. They also cover date validation, midnight crossings, missing jobs, delayed observations, state transitions and conflicting retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the lab doesn’t cover
&lt;/h3&gt;

&lt;p&gt;Output and evidence live in one SQLite database, so the atomic part holds there and nowhere else. Delta, S3 or a message broker need their own commit checks. The total is illustrative too; the gate doesn’t read upstream tables.&lt;/p&gt;

&lt;p&gt;On Databricks, I’d pass business date as a job parameter and let a table-update trigger on a control table start the check. The trigger only decides when to look. The date and attempt rules above still decide if the output goes out.&lt;/p&gt;

&lt;p&gt;Before deploying, I’d test the collector’s pagination, retries and repair handling, and map the real run states explicitly.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/e3ddbf036417f64d4e2e8b1058df15780b935820/python/business_date_gate" rel="noopener noreferrer"&gt;https://github.com/jenniferevangelista-de/data-engineering-lab/tree/e3ddbf036417f64d4e2e8b1058df15780b935820/python/business_date_gate&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;References:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Databricks run-list filters: &lt;a href="https://docs.databricks.com/api/jobs/v2/list-runs" rel="noopener noreferrer"&gt;https://docs.databricks.com/api/jobs/v2/list-runs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Job parameters: &lt;a href="https://docs.databricks.com/aws/en/jobs/job-parameters" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/jobs/job-parameters&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Table-update triggers: &lt;a href="https://docs.databricks.com/aws/en/jobs/trigger-table-update" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/jobs/trigger-table-update&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SQLite transactions: &lt;a href="https://www.sqlite.org/lang_transaction.html" rel="noopener noreferrer"&gt;https://www.sqlite.org/lang_transaction.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>databricks</category>
      <category>dataengineering</category>
      <category>dataquality</category>
    </item>
    <item>
      <title>A Freshness Check Before the 8 a.m. Report</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:48:57 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/a-freshness-check-before-the-8-am-report-3g2n</link>
      <guid>https://dev.to/jenniferevangelistade/a-freshness-check-before-the-8-am-report-3g2n</guid>
      <description>&lt;p&gt;Check the output interval and event timestamps before the report becomes available to consumers.&lt;/p&gt;

&lt;p&gt;The dashboard says the overnight pipeline succeeded. The BI report opened at 8:00 a.m. People are already discussing the morning numbers.&lt;/p&gt;

&lt;p&gt;Someone from operations asks why yesterday’s transactions are still the newest records.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nn16c1tsvq6b11wntu9.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nn16c1tsvq6b11wntu9.jpeg" width="800" height="443"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Pipeline finished successfully.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this example, every expected task succeeded. The pipeline copied an old snapshot into a valid new file, and no task checked its event timestamps. I would put that check at the boundary where the report becomes available to consumers.&lt;/p&gt;
&lt;h3&gt;
  
  
  Three clocks, three different questions
&lt;/h3&gt;

&lt;p&gt;Teams often compress distinct timestamps into a single idea of “latest”:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The run clock:&lt;/strong&gt; when the orchestration system started and finished a run. This helps answer whether the workflow executed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interval clock:&lt;/strong&gt; the logical period of data assigned to the run. In Airflow, a time-scheduled run has a data interval; its logical date commonly denotes that interval’s start, &lt;strong&gt;not&lt;/strong&gt; the moment the tasks physically execute. A run for Monday’s data may legitimately execute after Monday has ended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The event clock:&lt;/strong&gt; the newest business event actually represented in the output. If an hourly orders dataset finishes at 8:00 but its newest order_created_at is 5:00, you have a freshness question even if the DAG status says success.&lt;/p&gt;

&lt;p&gt;The three clocks can agree, disagree, or be incomparable depending on what the source produces. Never use a scheduler timestamp as a substitute for inspecting output events.&lt;/p&gt;
&lt;h3&gt;
  
  
  How a green DAG can deliver old data
&lt;/h3&gt;

&lt;p&gt;Consider an hourly DAG that extracts orders, transforms them, and writes a report table. A source API is delayed but still returns a well-formed response. The transform accepts the response. The write succeeds. A terminal task sends the success notification. Yesterday’s data has completed today’s commute.&lt;/p&gt;

&lt;p&gt;Nothing in that story proves that the output contains events within the business SLA.&lt;/p&gt;

&lt;p&gt;There is another nuance: Airflow’s DAG-run status is derived from task states, including leaf tasks. Its documentation warns that certain trigger-rule arrangements can produce an apparently successful DAG run even when an intermediate task failed. A status light is useful, but it is not an end-to-end assertion about the delivered dataset.&lt;/p&gt;

&lt;p&gt;This is why I do not recommend solving freshness incidents by simply adding more retries. Retrying a successful extraction of an old snapshot may give you the same old snapshot again.&lt;/p&gt;
&lt;h3&gt;
  
  
  Define freshness from the consumer backward
&lt;/h3&gt;

&lt;p&gt;For this hourly orders example, I would agree on the following contract with the consumer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The hourly orders dataset must contain at least one valid row, must be produced for the required interval, and must contain business event timestamps no older than 90 minutes at the 8:00 reporting checkpoint.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The 90-minute number is a &lt;strong&gt;made-up example SLA&lt;/strong&gt; , not a recommendation for every company. For a dataset that receives no orders on quiet days, a mandatory recent event may be an invalid contract; use source heartbeat, completeness markers, or expected arrival windows instead. For batch snapshots, partition publication time may be more informative than event time. The right contract reflects producer behavior and consumer expectations.&lt;/p&gt;

&lt;p&gt;Before marking a dataset ready, I want answers to five specific questions: Did the job publish the required data interval, or did it succeed while reprocessing an older one? Is the newest event recent enough for this consumer? Is the expected partition or count present, or are missing records being silently tolerated? Did the agreed validation checks pass before the table became visible downstream? Are timestamps timezone-aware, is event time distinct from ingestion time, and is clock skew bounded?&lt;/p&gt;

&lt;p&gt;These are properties of the output and its contract, not properties of the colored dot next to a task.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fail at the publication boundary
&lt;/h3&gt;

&lt;p&gt;The companion lab contains a small Python contract that demonstrates the idea without requiring Airflow, Spark, or cloud credentials:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/airflow/day_001_freshness_contract" rel="noopener noreferrer"&gt;&lt;strong&gt;Open the freshness-contract lab and tests on GitHub&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core function accepts a snapshot description, the current UTC instant, the required interval end, and a maximum event age. It returns specific breaches for an old interval, stale events, empty output, failed quality checks, or implausibly future-dated events. A strict wrapper raises an error so the publishing task can fail closed rather than completing with bad data.&lt;/p&gt;

&lt;p&gt;For example, the synthetic case is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;freshness_contract&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assess_snapshot&lt;/span&gt;

&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_hourly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dag_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data_interval_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latest_event_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;row_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quality_checks_passed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;assess_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;required_interval_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ('event timestamp too old',)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output reports event timestamp too old: the latest event is 120 minutes old against the example limit of 90 minutes, even though dag_state is success.&lt;/p&gt;

&lt;p&gt;Run the standalone tests with Python 3.12+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; airflow/day_001_freshness_contract &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'test_*.py'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests cover stale-but-green output, the wrong interval, missing timestamps, empty output, quality failures, invalid timezone handling, clock skew and a healthy dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; this is an original pure-Python illustration of a guard. It is &lt;strong&gt;not an installed Airflow operator&lt;/strong&gt; and has not been exercised against an Airflow scheduler, Databricks, cloud storage, or production data. To deploy it, fetch real metadata from the produced dataset and invoke the strict check before marking the artifact ready for downstream use.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about asset-aware orchestration?
&lt;/h3&gt;

&lt;p&gt;Airflow 3 includes asset-aware scheduling, which is valuable when downstream workflows should respond to upstream asset events instead of an arbitrary timer. For task-declared outlets, Airflow marks an asset as updated when the producer task completes successfully. Externally emitted asset events need their own producer-side validation. Neither path inherently certifies every business-quality dimension of the data. Put the contract check before the event is emitted, or make consumers verify the contract independently.&lt;/p&gt;

&lt;p&gt;Asset-aware scheduling solves &lt;strong&gt;when to trigger&lt;/strong&gt;. Data contracts answer &lt;strong&gt;what is safe to consume&lt;/strong&gt;. They complement each other; they are not substitutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The incident-review questions I would ask
&lt;/h3&gt;

&lt;p&gt;Did the source publish new business events? What is the newest event timestamp and what does that field actually represent? Which interval did the job write? Can we distinguish a source outage from a legitimate zero-activity period? Was the output made visible only after validation passed? What alert goes to the owner when the contract is breached?&lt;/p&gt;

&lt;p&gt;Those questions lead to an operational action. “Make the DAG green” usually leads to another green DAG.&lt;/p&gt;

&lt;p&gt;If I had to choose one dashboard tile for an executive review, I would show &lt;strong&gt;data availability against the consumer’s agreed freshness contract&lt;/strong&gt; , backed by evidence, alongside workflow status. The scheduler should remain visible. It should not be the sole source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A green DAG means the orchestration ran. A trustworthy dataset means the data met its promises.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html" rel="noopener noreferrer"&gt;Apache Airflow DAG runs: status, logical date, and data intervals&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://airflow.apache.org/docs/apache-airflow/stable/authoring-and-scheduling/asset-scheduling.html" rel="noopener noreferrer"&gt;Apache Airflow asset-aware scheduling&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/airflow/day_001_freshness_contract" rel="noopener noreferrer"&gt;Reproducible freshness-contract lab&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foha5h9eui2goq8wpky7w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foha5h9eui2goq8wpky7w.png" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated illustration of a successful workflow delivering stale data.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apacheairflow</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Three Orders In, Three Rows Out, 42.9% More Revenue</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:34:51 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/three-orders-in-three-rows-out-429-more-revenue-cee</link>
      <guid>https://dev.to/jenniferevangelistade/three-orders-in-three-rows-out-429-more-revenue-cee</guid>
      <description>&lt;p&gt;&lt;em&gt;An INNER JOIN can lose orders and multiply others in the same query. Here is a reproducible test for the failure that aggregate dashboards often miss.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafyacl2c2ltreu17mrv3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafyacl2c2ltreu17mrv3.png" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated illustration of duplicate matches and an unmatched order. Synthetic amounts in integer cents.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A join can return the same number of rows as its input and still inflate revenue. In this synthetic example, three orders become three output rows, while the total rises from 17,500 to 25,000 cents: an increase of about 42.9%.&lt;/p&gt;

&lt;p&gt;The row-count check approves. Accounting would like a word.&lt;/p&gt;

&lt;p&gt;The aggregate count is unchanged because one duplicated order compensates for one missing order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb0ntg83fbvlitqgwkbr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb0ntg83fbvlitqgwkbr.png" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated comic about a row-count check that misses a revenue mismatch.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The join that cancels its own mistakes
&lt;/h3&gt;

&lt;p&gt;We have three synthetic orders, in integer cents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order customer amount_cents
o1 c1 10000
o2 c2 5000
o3 c3 2500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source total is &lt;strong&gt;17,500 cents&lt;/strong&gt;. Our customer profile table has two historical versions for c1, one for c2, and no profile for c3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customer_profiles&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;o1 matches two profile rows, so its 10,000 cents are counted twice.&lt;/li&gt;
&lt;li&gt;o2 matches once.&lt;/li&gt;
&lt;li&gt;o3 disappears because its customer has no profile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output is still three rows, but the sum is now 25,000 cents. We lost one fact row and duplicated another. A row-count assertion looks reassuring precisely because the two errors compensate numerically. The query assumes one matching profile per customer, but the data contains two matches for one customer and none for another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define the grain before writing the join
&lt;/h3&gt;

&lt;p&gt;The grain of the source is one row per order_id. The intended relationship is many orders to one &lt;em&gt;current&lt;/em&gt; customer profile. But the customer_profiles table actually stores multiple versions for at least one customer.&lt;/p&gt;

&lt;p&gt;An INNER JOIN only enforces its equality predicate. It does not invent a uniqueness constraint or choose “current” by intuition. A LEFT JOIN would preserve unmatched orders, but &lt;strong&gt;it would not prevent fanout&lt;/strong&gt; when two profiles match.&lt;/p&gt;

&lt;p&gt;Before joining, make the contract explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every order is associated with exactly one valid profile for the report’s business time.&lt;/li&gt;
&lt;li&gt;The right side contains no more than one profile row per join key &lt;em&gt;after applying that time or current-record rule&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;An unmatched order is an exception to investigate, not something silently removed.&lt;/li&gt;
&lt;li&gt;A revenue report reconciles both fact count &lt;strong&gt;and&lt;/strong&gt; monetary totals in integer cents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a Type 2 dimension, the correct fix is normally a temporal rule, such as selecting the version effective when the order happened. Choosing MAX(profile_version) without understanding the business definition of “current” is not a neutral fix; it can rewrite history. If the contract truly needs the latest known state rather than an as-of state, document that choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four checks that expose the issue
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Check 1: duplicate dimension keys at the join grain.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customer_profiles&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally conservative. In a history table, duplicates may be legitimate overall, but they are not legitimate &lt;strong&gt;in the dimension slice selected for a one-profile-per-customer join&lt;/strong&gt;. Filter or window the history according to the correct as-of rule first, then check that derived slice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check 2: unmatched orders.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customer_profiles&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this even if the row-count totals happen to match. If null business keys are allowed, specify their matching policy explicitly instead of relying on implicit SQL NULL behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check 3: reconcile totals before and after.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;row_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cents&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;row_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cents&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customer_profiles&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this example the counts both equal three, but the amounts disagree: 17,500 versus 25,000 cents. Finance-grade pipelines should avoid floating-point money and define how refunds, cancellations, currency, and tax are represented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check 4: fail the contract, not only the SQL execution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A query that returns rows has not proved its relationship assumptions. Treat fanout, unmatched facts, and unreconciled totals as observable contract breaches. Fail the publication gate before an incorrect result enters an executive dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  A small executable proof
&lt;/h3&gt;

&lt;p&gt;Spark join hints suggest execution strategies, such as broadcasting one side; they do not enforce one profile per customer. Keep the cardinality and reconciliation checks when porting this pattern, then evaluate the physical plan separately. The executable proof here remains SQLite-only.&lt;/p&gt;

&lt;p&gt;Spark JOIN reference: &lt;a href="https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-join.html" rel="noopener noreferrer"&gt;https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-join.html&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Join hints: &lt;a href="https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-hints.html" rel="noopener noreferrer"&gt;https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-hints.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The public lab includes a dependency-free SQLite example and eight tests:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/sql/day_002_join_cardinality" rel="noopener noreferrer"&gt;https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/sql/day_002_join_cardinality&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The example deliberately produces the deceptive equality of row counts, catches duplicate profile keys, catches orphan orders, compares totals, and refuses the publication contract. Tests also cover a healthy dimension, an empty input, and a refund represented as negative integer cents.&lt;/p&gt;

&lt;p&gt;Run it with Python 3.12+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; sql/day_002_join_cardinality &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'test_*.py'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
python sql/day_002_join_cardinality/join_guard.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; the tests run against SQLite with synthetic data. They do &lt;strong&gt;not&lt;/strong&gt; benchmark Spark, execute a Databricks job, or validate your production dimensional model. The behavioral principle is portable; query plans and enforcement mechanisms differ by engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would require in a production review
&lt;/h3&gt;

&lt;p&gt;A code review focused only on syntax and task status will not catch this class of incident. I would ask for a written grain, a documented as-of join rule, checks for right-side cardinality and unmatched keys, monetary reconciliation, and a failure path that blocks consumption when those checks fail.&lt;/p&gt;

&lt;p&gt;For an analytics system with late-arriving dimensions, the contract also needs a defined quarantine or remediation strategy. Do you defer the record, show it as “unknown,” or resolve it from an earlier version? A blanket INNER JOIN is not a policy.&lt;/p&gt;

&lt;p&gt;For this example, I would block the report until c1 has one profile selected by the agreed time rule and o3 has an explicit resolution. Then I would rerun the key, row-count, and money checks before making the result available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-qry-select-join" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-qry-select-join&lt;/a&gt; (INNER vs LEFT and join criteria).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/queries-table-expressions.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/queries-table-expressions.html&lt;/a&gt; (relational join behavior).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/sql/day_002_join_cardinality" rel="noopener noreferrer"&gt;https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/sql/day_002_join_cardinality&lt;/a&gt; (synthetic example and tests).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sql</category>
      <category>dataengineering</category>
      <category>databricks</category>
    </item>
    <item>
      <title>Unity Catalog Row Filters and Column Masks: A Fail-Closed Security Design You Can Test</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Mon, 19 Oct 2026 21:00:35 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/unity-catalog-row-filters-and-column-masks-a-fail-closed-security-design-you-can-test-1a84</link>
      <guid>https://dev.to/jenniferevangelistade/unity-catalog-row-filters-and-column-masks-a-fail-closed-security-design-you-can-test-1a84</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazt8mbqupcdb2udjmjuz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazt8mbqupcdb2udjmjuz.png" alt="Two-panel comic: a woman analyst shows a pii-readers badge at the orders table entrance. A security guard holding a ROW FILTER clipboard blocks entry and asks for her region." width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Original AI-generated illustration: column masking does not grant row access.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A data-governance slide says “only authorized analysts can see customer PII.” That sentence sounds reassuring until someone asks which identity was used for the test, whether null attributes are allowed through, and what happens when the same table is queried from a different notebook or SQL warehouse.&lt;/p&gt;

&lt;p&gt;I prefer a claim that can fail a test. For a particular table, an unauthorized principal should see &lt;strong&gt;no disallowed rows&lt;/strong&gt; and &lt;strong&gt;no raw protected values&lt;/strong&gt; , even when the query is not the one the policy author anticipated. Databricks Unity Catalog row filters and column masks provide table-level controls at query time, but their reliability depends on function logic, type compatibility, privilege management and execution context. For policies that must span many tables, Databricks now recommends attribute-based access control (ABAC) instead of maintaining a function attachment by hand on every table.&lt;/p&gt;

&lt;p&gt;Let’s build the smallest realistic demonstration and then attack our assumptions. A mask should protect the email column, not cosplay as an access-control strategy.&lt;/p&gt;
&lt;h3&gt;
  
  
  Row filtering and masking answer different questions
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;row filter&lt;/strong&gt; is a SQL user-defined function that returns a Boolean for each row. FALSE and NULL both mean the row is not returned. It is appropriate for conditions such as region, tenant, department or legal entity, once the identity source and authorization model are defined.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;column mask&lt;/strong&gt; is a SQL UDF applied when a protected column is read. It returns a value with a type compatible with the original column. A mask can preserve a value for an authorized group and return a redacted placeholder for everyone else. One table can have a row filter and multiple column masks, but each masked column has one active mask.&lt;/p&gt;

&lt;p&gt;The design rule is straightforward: a missing or unexpected condition should not broaden access. The SQL below uses an explicit allowlist for regions and a default-redacted mask.&lt;/p&gt;
&lt;h3&gt;
  
  
  Reproducible Databricks SQL lab
&lt;/h3&gt;

&lt;p&gt;This lab requires a Unity Catalog-enabled Databricks workspace, SQL warehouse or supported Databricks Runtime, a writable catalog, and permission to create a schema, functions and a test table. &lt;strong&gt;Replace&lt;/strong&gt;  &lt;strong&gt;main with your authorized catalog.&lt;/strong&gt; The group names are fictional and must exist in your account for the identity-specific test matrix. All customer data below is fake. Do not run this against a production table.&lt;/p&gt;

&lt;p&gt;Run the following statements in a Databricks SQL editor. An account admin should provision three test groups: governance-admins, br-analysts and pii-readers. The testers must also receive the ordinary privileges necessary to query the demo table. These groups are demonstrations, not a suggested enterprise naming standard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_region&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt;
  &lt;span class="n"&gt;is_account_group_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'governance-admins'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BR'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_account_group_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'br-analysts'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mask_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;is_account_group_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pii-readers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;raw_email&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'REDACTED'&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;DELTA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'BR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ana@example.invalid'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'US'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bob@example.invalid'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown@example.invalid'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_region&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_email&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;MASK&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mask_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What should this return? &lt;strong&gt;It depends on the querying principal.&lt;/strong&gt; You cannot adequately validate the policy by rerunning SELECT as its owner and assuming every analyst has the same view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying identity:&lt;/strong&gt; Member of br-analysts only; &lt;strong&gt;Expected rows:&lt;/strong&gt; Order 1; &lt;strong&gt;Expected email values:&lt;/strong&gt;  REDACTED&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying identity:&lt;/strong&gt; Member of br-analysts and pii-readers; &lt;strong&gt;Expected rows:&lt;/strong&gt; Order 1; &lt;strong&gt;Expected email values:&lt;/strong&gt; &lt;a href="mailto:ana@example.invalid"&gt;ana@example.invalid&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying identity:&lt;/strong&gt; Member of governance-admins but not pii-readers; &lt;strong&gt;Expected rows:&lt;/strong&gt; Orders 1, 2, 3; &lt;strong&gt;Expected email values:&lt;/strong&gt; All REDACTED&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying identity:&lt;/strong&gt; Member of none of these groups; &lt;strong&gt;Expected rows:&lt;/strong&gt; No rows; &lt;strong&gt;Expected email values:&lt;/strong&gt; No values&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying identity:&lt;/strong&gt; Member of pii-readers only; &lt;strong&gt;Expected rows:&lt;/strong&gt; No rows; &lt;strong&gt;Expected email values:&lt;/strong&gt; No values&lt;/p&gt;

&lt;p&gt;Two details are intentional. First, the null region is &lt;strong&gt;not visible&lt;/strong&gt; to a Brazil-only analyst because the allow condition is not true for that record. Second, membership in pii-readers does not grant row access on its own. The mask governs &lt;em&gt;values of rows that survived the filter&lt;/em&gt;; it does not manufacture access to protected rows.&lt;/p&gt;

&lt;p&gt;One nuance matters for the third row: NULL passed through allow_region is allowed &lt;strong&gt;only&lt;/strong&gt; when the governance-admins branch is true. Everyone else sees it filtered out. That is a conscious administrative rule, and it should be reviewed as part of the access model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tests worth automating
&lt;/h3&gt;

&lt;p&gt;A screenshot from the SQL editor is not a security test suite. I would maintain a test matrix with the expected counts and masked values for several dedicated non-production principals. Repeat it whenever group assignments, the UDFs or the table schema change.&lt;/p&gt;

&lt;p&gt;At minimum, automate these acceptance checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A principal without any allowed groups gets zero rows. This tests the default-deny path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A Brazil-only analyst cannot retrieve the US row, including by joining on a supposedly hidden identifier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A PII-only principal does not gain region permissions by accident.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An analyst loses raw email access immediately after removal from pii-readers, subject to normal identity propagation and cache behavior in your environment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A null or unexpected region does not appear to a region-limited analyst.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Query plans and performance remain acceptable with the filter and mask enabled.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A test harness can run SELECT COUNT(*) and SELECT customer_email as each principal and compare the result set to a fixed contract. &lt;strong&gt;Do not rely on a session variable that pretends to change the current account identity&lt;/strong&gt; ; the authorization functions use the caller's real identity and group membership.&lt;/p&gt;

&lt;h3&gt;
  
  
  The most dangerous bug may be a data type, not a missing policy
&lt;/h3&gt;

&lt;p&gt;Databricks documentation describes a subtle failure mode: if a table column and its UDF parameter have different types, implicit casts occur. With ANSI mode disabled, values that cannot be cast can become NULL silently. A filter written with an unfortunate IS NULL branch could unintentionally allow rows after such a cast.&lt;/p&gt;

&lt;p&gt;Keep the UDF signature aligned with the actual column type, prefer predictable expressions, and validate policies with data containing nulls and malformed values. For sensitive workloads, follow the documented recommendation around ANSI behavior and type matching rather than trusting a policy simply because it compiled.&lt;/p&gt;

&lt;p&gt;Also keep the masking function as simple as possible. More arguments, Python UDFs or mapping-table lookups can cost optimization opportunities. Filtering and masking are a security boundary: the engine may prioritize correct policy enforcement over aggressive pushdown, and that tradeoff can show up in query latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Table-level rules, ABAC and dynamic views are not interchangeable
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Manual table-level UDF attachment&lt;/strong&gt; is a good fit when there are few tables, well-defined owners and localized requirements. It is a weaker governance operating model when hundreds of independently managed tables need the same sensitive-column policy. A table owner or authorized manager can change a manual attachment, so your organization must audit changes and control policy administration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ABAC policies&lt;/strong&gt; are a stronger default for centrally managed rules: policy functions can be attached at catalog or schema scope and target tables/columns using governed tags. This helps new tagged tables inherit policies and separates stewardship from authoring. Databricks recommends ABAC for consistent filtering and masking across many tables; verify runtime and privilege requirements before adopting SQL CREATE POLICY in an older workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic views&lt;/strong&gt; are useful when consumers should access only a curated SQL interface, such as a joined or transformed subset of base tables. They are not the same as attaching a table-level rule to every direct reader of the base table. The choice should follow the threat model and how users are actually granted access.&lt;/p&gt;

&lt;p&gt;None of these replaces clean catalog permissions. Users need the appropriate table and schema privileges in addition to whatever row-level or column-level policy logic applies. In other words, a mask is not permission to query everything, and a SELECT grant is not permission to ignore a mask.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production limitations you should put in the architecture decision
&lt;/h3&gt;

&lt;p&gt;Databricks lists important constraints for tables with row filters or column masks. Among them: path-based file access to protected tables is not a supported way to query through the policy; time travel and cloning have restrictions; some Delta APIs and interoperability surfaces are unsupported; certain UPDATE, DELETE and MERGE patterns have runtime-dependent limitations. Dedicated-access compute has additional version and serverless fine-grained-access-control requirements. These constraints are &lt;strong&gt;design inputs&lt;/strong&gt; , not footnotes to discover after migration.&lt;/p&gt;

&lt;p&gt;The example uses manual table-level policies and therefore should not be mistaken for a demonstration of centralized ABAC. The SQL syntax can be checked against documentation, but there is &lt;strong&gt;no claim here that the example has been executed in a Databricks workspace&lt;/strong&gt;. Group creation, permissions, supported compute/runtime and behavior under each identity must be validated there before use in production.&lt;/p&gt;

&lt;p&gt;For lab cleanup, detach the policies before dropping their functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;MASK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mask_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jta_governance_demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_region&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Drop the demo schema only if nothing else uses it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detaching first matters. Databricks explicitly warns that deleting a referenced masking function while leaving the mask attached can leave a table inaccessible until the orphaned reference is removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;Good governance is a set of falsifiable expectations: &lt;em&gt;which identity can see which row, and which value, under which compute path?&lt;/em&gt; Start with a small allowlist, design for missing attributes, test several principals, and write down what you have not tested. For organization-wide coverage, move beyond copy-paste table functions toward centrally governed ABAC and audit your permissions continuously.&lt;/p&gt;

&lt;p&gt;A secure dashboard is not the number of policies created. It is the number of unauthorized paths you have proved do not work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Official sources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Databricks, &lt;strong&gt;Row filters and column masks&lt;/strong&gt; (overview, ABAC choice, compatibility and limits): &lt;a href="https://docs.databricks.com/aws/en/data-governance/unity-catalog/filters-and-masks/" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/data-governance/unity-catalog/filters-and-masks/&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Databricks, &lt;strong&gt;Manually apply row filters and column masks&lt;/strong&gt; (UDF syntax, SET ROW FILTER, SET MASK, removing policies): &lt;a href="https://docs.databricks.com/aws/en/data-governance/unity-catalog/filters-and-masks/manually-apply" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/data-governance/unity-catalog/filters-and-masks/manually-apply&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Databricks, &lt;strong&gt;ROW FILTER clause&lt;/strong&gt; : &lt;a href="https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-row-filter" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-row-filter&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Databricks, &lt;strong&gt;Column mask clause&lt;/strong&gt; : &lt;a href="https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-column-mask" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-column-mask&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Databricks, &lt;strong&gt;CREATE POLICY&lt;/strong&gt; (ABAC): &lt;a href="https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-create-policy" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-create-policy&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Continue in the lab
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab" rel="noopener noreferrer"&gt;Data Engineering Lab&lt;/a&gt; separates runnable examples from service-integration claims. Its &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/databricks/community_lab" rel="noopener noreferrer"&gt;Community lab&lt;/a&gt; demonstrates synthetic SQL checks and explicit NULL handling; it is a testing reference, not a Unity Catalog authorization test suite. The identity matrix in this article still needs an isolated Databricks workspace.&lt;/p&gt;

&lt;p&gt;For the complementary data-correctness contract, read &lt;a href="https://medium.com/@jenniferevangelista.code/your-delta-merge-is-not-a-mind-reader-16e81b6c788b" rel="noopener noreferrer"&gt;Your Delta MERGE Is Not a Mind Reader&lt;/a&gt;. Recovery and replay are covered in &lt;a href="https://medium.com/@jenniferevangelista.code/your-s3-pipeline-needs-a-replay-plan-not-more-optimism-a62342b93e89" rel="noopener noreferrer"&gt;Your S3 Pipeline Needs a Replay Plan, Not More Optimism&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>databricks</category>
    </item>
    <item>
      <title>Your Kafka Consumer Lag Is Not Your SLA: Offset Math, Retention, and the Missing-Commit Trap</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Mon, 19 Oct 2026 20:58:58 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/your-kafka-consumer-lag-is-not-your-sla-offset-math-retention-and-the-missing-commit-trap-4pm0</link>
      <guid>https://dev.to/jenniferevangelistade/your-kafka-consumer-lag-is-not-your-sla-offset-math-retention-and-the-missing-commit-trap-4pm0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F431i4u1q7aq9occc26vh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F431i4u1q7aq9occc26vh.png" alt="A woman detective unmasks a smiling Kafka dashboard labeled lag = 0, revealing a ghost labeled committed offset = null." width="800" height="1000"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Original AI-generated illustration: a zero-lag dashboard can hide a missing committed offset.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A dashboard declares that your Kafka consumer lag is zero. Support closes the incident. The business report is still missing records.&lt;/p&gt;

&lt;p&gt;The chart is happy. The business would like its data back.&lt;/p&gt;

&lt;p&gt;The apparent contradiction is not necessarily a Kafka defect. It may be a defect in the &lt;em&gt;meaning&lt;/em&gt; assigned to the metric. Kafka offsets tell us how a particular reader relates to a particular partition’s log. They do not, on their own, certify that every business event was processed, that a downstream database commit succeeded, or that a latency target was met.&lt;/p&gt;

&lt;p&gt;The distinction matters because the common formula, log_end_offset - committed_offset, is only meaningful after checking that both offsets exist, refer to the same topic-partition and are compatible with the log's retention boundary. Before summing anything across partitions, I want to know which measurements are trustworthy.&lt;/p&gt;
&lt;h3&gt;
  
  
  Three offsets, three different questions
&lt;/h3&gt;

&lt;p&gt;For each topic-partition, collect these values:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Beginning offset (B).&lt;/strong&gt; The earliest offset still available to read from the log. Retention and log compaction can affect the relationship between offsets and actual accessible records.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;End offset (E).&lt;/strong&gt; The boundary representing the next offset at the end of the readable log, according to the API and isolation level used by your measurement.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Committed consumer-group offset, C. The offset the group has recorded as its next position. A committed offset is not a receipt from your destination system.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When B &amp;lt;= C &amp;lt;= E, the familiar backlog estimate is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;partition_offset_lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;C&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example: if a group has committed offset 140 and the observed end is 150, the offset lag is 10. If C equals E, lag is zero. That is a &lt;em&gt;specific statement about the observed positions&lt;/em&gt;, not proof of end-to-end correctness.&lt;/p&gt;

&lt;p&gt;The assumption that breaks many homegrown monitors is that C is always present and always falls in that range. It isn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four statuses instead of one deceptive number
&lt;/h3&gt;

&lt;p&gt;Consider an orders topic observed at 08:00. Four partitions show different states:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition:&lt;/strong&gt; 0; &lt;strong&gt;B:&lt;/strong&gt; 100; &lt;strong&gt;E:&lt;/strong&gt; 150; &lt;strong&gt;C:&lt;/strong&gt; 140; &lt;strong&gt;Correct interpretation:&lt;/strong&gt; Offset lag = 10; position is within the retained range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition:&lt;/strong&gt; 1; &lt;strong&gt;B:&lt;/strong&gt; 200; &lt;strong&gt;E:&lt;/strong&gt; 260; &lt;strong&gt;C:&lt;/strong&gt; null; &lt;strong&gt;Correct interpretation:&lt;/strong&gt; No committed offset is available. Lag is &lt;strong&gt;unknown&lt;/strong&gt; , not zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition:&lt;/strong&gt; 2; &lt;strong&gt;B:&lt;/strong&gt; 500; &lt;strong&gt;E:&lt;/strong&gt; 620; &lt;strong&gt;C:&lt;/strong&gt; 480; &lt;strong&gt;Correct interpretation:&lt;/strong&gt; The committed position precedes retention by 20 offsets. Investigate potential unrecoverable gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition:&lt;/strong&gt; 3; &lt;strong&gt;B:&lt;/strong&gt; 700; &lt;strong&gt;E:&lt;/strong&gt; 740; &lt;strong&gt;C:&lt;/strong&gt; 750; &lt;strong&gt;Correct interpretation:&lt;/strong&gt; The committed position is beyond the observed end. Validate snapshot consistency and log history.&lt;/p&gt;

&lt;p&gt;These become four operational states:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OK:&lt;/strong&gt; B &amp;lt;= C &amp;lt;= E. Compute E - C.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NO_COMMITTED_OFFSET:&lt;/strong&gt; C is absent. Do not substitute zero. This may be a new group, a deleted commit, a partition with no recorded group offset, or a mismatch between group and topic-partition inventory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BEHIND_RETENTION:&lt;/strong&gt; C &amp;lt; B. The span B - C is an unavailable &lt;em&gt;offset range&lt;/em&gt;. It is not automatically the number of lost business events, especially on compacted topics, but it is a concrete reason to stop presenting the group as healthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AHEAD_OF_LOG_END:&lt;/strong&gt; C &amp;gt; E. Never clamp a negative result to zero. A racing measurement, broker/log changes or an incorrect partition association could have produced inconsistent boundaries; investigate before claiming anything about throughput.&lt;/p&gt;

&lt;p&gt;When even one partition is unknown or inconsistent, my group-level total_lag is null. A partial sum of healthy partitions can still be shown, but it must not be labeled the total.&lt;/p&gt;

&lt;h3&gt;
  
  
  A reproducible audit in plain Python
&lt;/h3&gt;

&lt;p&gt;You can test these rules with synthetic snapshots without standing up Kafka. Save this as lag_demo.py and run python lag_demo.py with Python 3.10 or later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;beginning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beginning&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid retained log boundary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO_COMMITTED_OFFSET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;negative committed offset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEHIND_RETENTION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AHEAD_OF_LOG_END&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;snapshots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;620&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;740&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partition=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, status=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, lag=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO_COMMITTED_OFFSET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEHIND_RETENTION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AHEAD_OF_LOG_END&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;partition=0, status=OK, lag=10
partition=1, status=NO_COMMITTED_OFFSET, lag=None
partition=2, status=BEHIND_RETENTION, lag=None
partition=3, status=AHEAD_OF_LOG_END, lag=None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example classifies provided snapshots; it does &lt;strong&gt;not&lt;/strong&gt; connect to a broker. A production integration would fetch committed group offsets through Kafka’s Admin API (listConsumerGroupOffsets), and the beginning/end boundaries through listOffsets, grouping by exact topic and partition. It would also attach group_id, collection timestamp, cluster identity and client/isolation configuration to each snapshot. Because these calls are not an atomic multi-partition snapshot, transient inconsistencies should trigger a refresh and diagnosis rather than automatic data-loss claims.&lt;/p&gt;

&lt;p&gt;The collector and the calculation should be separate components. The code above is the complete local calculation demo for this article; the public lab does not yet contain a Kafka collector or broker integration. For the same unknown-versus-zero principle in a runnable Spark example, see &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/blob/main/databricks/community_lab/queries/optional_operation_metrics.sql" rel="noopener noreferrer"&gt;optional operation metrics in my Community lab&lt;/a&gt;. That example reads synthetic metric maps, not Kafka offsets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why low lag can coexist with a broken pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Commits can run ahead of downstream durability.&lt;/strong&gt; A consumer might commit offsets and then fail while writing to its warehouse. After restart, the consumer group position can look healthy while the target lacks events. Whether this is possible in a particular architecture depends on commit ordering, destination guarantees and transaction design. Kafka’s at-least-once and exactly-once discussions are about specific processing boundaries, not a magic property of the number in a dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A group can be fast but stale.&lt;/strong&gt; A source may not have received events for an hour. Its end offset does not move, so its consumer can show zero lag. Your business freshness SLO is still breached. Measure source freshness, event-time age and sink visibility separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One aggregate hides a hot partition.&lt;/strong&gt; A group with 200 partitions can have modest total lag but one partition carrying the orders that matter. Keep per-partition maxima and age percentiles, not just sum and average.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offsets are not a wall clock.&lt;/strong&gt; E - C = 10,000 can mean seconds or hours depending on the arrival rate. Converting count to time by dividing by a short-term rate is an estimate that fails when traffic is bursty or paused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retention changes recovery choices.&lt;/strong&gt; If C falls below B, the consumer may need a policy decision about offset reset or reprocessing from a separate durable source. An automatic reset-to-latest can make the lag chart beautiful while discarding the very history the incident team needs. Define this behavior explicitly and log the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compacted topics complicate counts.&lt;/strong&gt; Offsets remain positions in the log, but not every offset within a numeric range necessarily represents a currently readable user record. Do not market offset differences as exact record counts for compacted workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  An on-call contract I would actually sign
&lt;/h3&gt;

&lt;p&gt;A useful consumer dashboard has at least three panels. &lt;strong&gt;Position health&lt;/strong&gt; shows per-partition B/C/E, group assignment, committed-offset availability and retention gaps. &lt;strong&gt;Pipeline health&lt;/strong&gt; tracks successful writes, retries, rejected records, deduplication/transaction results and error budgets. &lt;strong&gt;Business freshness&lt;/strong&gt; measures when the most recent valid business event became queryable in the serving layer, relative to the time it was expected.&lt;/p&gt;

&lt;p&gt;Alerts should distinguish “backlog above threshold” from “cannot calculate lag,” “committed position outside retention,” and “data not visible downstream.” The first is often a capacity or rate problem; the others can be correctness incidents. Treating all four as the same red/green threshold destroys useful diagnosis.&lt;/p&gt;

&lt;p&gt;When a pipeline is repaired, I would require a recovery test: record a known input key, trace its topic-partition-offset, verify the target effect, force a controlled retry, and confirm the intended duplicate-handling behavior. A lag graph cannot run this test for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limitations and where to be careful
&lt;/h3&gt;

&lt;p&gt;The Python example is a &lt;strong&gt;local calculation test with fabricated values&lt;/strong&gt;. It does not validate authorization, Kafka brokers, consumer rebalances, committed-offset races, aborted transactions, compaction behavior or sink idempotency. Kafka’s Admin and consumer APIs differ in how they expose group state and offset boundaries; production collectors need a tested client/version policy. Do not infer that every missing committed offset means data loss, or that every retention gap equals lost events.&lt;/p&gt;

&lt;p&gt;If you use Structured Streaming with Kafka, its checkpointed progress and sink semantics must be analyzed as a separate implementation, not conflated with an arbitrary Kafka consumer group’s commits. That is another reason I keep source recovery, pipeline state and business outcomes as separate evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;Offset lag is useful once you have proved that the inputs make sense. It tells you how far a recorded position is from an observed log boundary. It doesn’t answer whether the pipeline fulfilled its contract. Keep unknowns unknown, surface retention boundaries, and make the business SLO visible on its own terms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Official sources
&lt;/h3&gt;

&lt;p&gt;Apache Kafka, &lt;a href="https://kafka.apache.org/41/javadoc/org/apache/kafka/clients/admin/Admin.html" rel="noopener noreferrer"&gt;Admin API&lt;/a&gt; and &lt;a href="https://kafka.apache.org/41/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html" rel="noopener noreferrer"&gt;consumer API&lt;/a&gt;; &lt;a href="https://kafka.apache.org/documentation/" rel="noopener noreferrer"&gt;consumer groups and delivery semantics&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Continue in the lab
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab" rel="noopener noreferrer"&gt;Data Engineering Lab&lt;/a&gt; contains small reproducible examples and explicit validation limits. &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/pyspark/day_001_cdc" rel="noopener noreferrer"&gt;CDC preparation&lt;/a&gt; covers replay, ordering and conflicting versions with synthetic data; it does not validate a live Kafka sink.&lt;/p&gt;

&lt;p&gt;For recovery design, read &lt;a href="https://medium.com/@jenniferevangelista.code/your-s3-pipeline-needs-a-replay-plan-not-more-optimism-a62342b93e89" rel="noopener noreferrer"&gt;Your S3 Pipeline Needs a Replay Plan, Not More Optimism&lt;/a&gt;. For deterministic target updates, read &lt;a href="https://medium.com/@jenniferevangelista.code/your-delta-merge-is-not-a-mind-reader-16e81b6c788b" rel="noopener noreferrer"&gt;Your Delta MERGE Is Not a Mind Reader&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kafka</category>
    </item>
    <item>
      <title>Your S3 Pipeline Needs a Replay Plan, Not More Optimism</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Sun, 18 Oct 2026 11:06:23 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/your-s3-pipeline-needs-a-replay-plan-not-more-optimism-3def</link>
      <guid>https://dev.to/jenniferevangelistade/your-s3-pipeline-needs-a-replay-plan-not-more-optimism-3def</guid>
      <description>&lt;p&gt;&lt;strong&gt;How file checkpoints, event identity, and source versions work together in a Databricks ingestion design on AWS.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiym3r65a2p3hme450mie.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiym3r65a2p3hme450mie.jpeg" width="516" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A file lands in S3. The job turns green. Everyone enjoys a brief, suspiciously peaceful moment.&lt;/p&gt;

&lt;p&gt;Then a recovery process delivers the same business events in a different file. The ingestion reads both objects correctly, and the downstream table now has duplicate entities or an older customer address. The green indicator has done its job: it reported that the process finished. Unfortunately, nobody had asked it to settle questions of identity and history.&lt;/p&gt;

&lt;p&gt;Reliable replay starts by giving each layer a smaller, clearer responsibility. A file checkpoint, an event identifier, and a source version solve different problems.&lt;/p&gt;

&lt;p&gt;This reference design includes a sandbox configuration and a recovery rehearsal plan.&lt;/p&gt;

&lt;p&gt;This article assumes familiarity with Auto Loader, Delta Lake, and structured streaming on Databricks. If you are new to those concepts, the Auto Loader overview and Delta Lake documentation are good starting points before continuing.&lt;/p&gt;

&lt;h3&gt;
  
  
  There are three different things to recognize
&lt;/h3&gt;

&lt;p&gt;First, identify the physical input. Which object did the reader discover and process? File-level identity and ingestion metadata help answer that question and support recovery of the reading process.&lt;/p&gt;

&lt;p&gt;Second, identify the logical event. Two different objects can carry the same source event. Reading each object once does not establish that each business event appears once.&lt;/p&gt;

&lt;p&gt;Third, identify precedence for an entity. A customer can have many legitimate events. A current-state table must know which version takes precedence, while a history table may need to retain every meaningful transition.&lt;/p&gt;

&lt;p&gt;Write down the contract before choosing a deduplication expression. For each layer, name the identity, the ordering rule, the treatment of conflict, and the recovery behavior. “Exactly once” is more informative when followed by an explanation of exactly what is counted once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give the layers concrete responsibilities
&lt;/h3&gt;

&lt;p&gt;A small reference architecture might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Producer -&amp;gt; S3 -&amp;gt; Auto Loader -&amp;gt; Bronze Delta
                                     |
                           validation / quarantine
                                     |
                         Silver state or history
                                     |
                          contract-based consumption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bronze preserves received data and provenance. Silver applies the business interpretation: keys, versions, operations, and the destination model. Consumers receive a documented view rather than inheriting every ingestion assumption.&lt;/p&gt;

&lt;p&gt;These layer names are convenient, not mandatory. The useful design decisions are who owns each output, how long it is retained, how it is rebuilt, and which failures stop a batch from being promoted.&lt;/p&gt;

&lt;p&gt;S3 notifications can be delivered more than once, and their arrival does not establish a global business order. Treat a notification as a discovery signal, not as a reliable “this customer version is newest” certificate. &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-event-types-and-destinations.html" rel="noopener noreferrer"&gt;Amazon S3 documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A checkpoint tracks the reader’s progress
&lt;/h3&gt;

&lt;p&gt;Auto Loader uses checkpoint-associated state to track discovered files. Its supported file-ingestion guarantees do not turn repeated events inside different files into unique business events. &lt;a href="https://docs.databricks.com/aws/en/ingestion/cloud-object-storage/auto-loader/" rel="noopener noreferrer"&gt;Auto Loader overview&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here is an illustrative sandbox configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="c1"&gt;# Replace these with authorized sandbox locations and identifiers.
&lt;/span&gt;&lt;span class="n"&gt;source_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://YOUR_BUCKET/customer-events/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;schema_location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://YOUR_STATE_BUCKET/customer-schema/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;checkpoint_location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://YOUR_STATE_BUCKET/customer-checkpoint/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;bronze_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sandbox.ingestion.customer_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloudFiles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloudFiles.format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloudFiles.schemaLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema_location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloudFiles.schemaEvolutionMode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rescuedDataColumn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_rescued_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id STRING, source_version BIGINT, value STRING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_metadata.file_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_metadata.file_modification_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_file_mtime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_timestamp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingested_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeStream&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpointLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checkpoint_location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;availableNow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bronze_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaitTermination&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selected metadata fields record the source file and its modification time alongside the ingestion timestamp. This makes it possible to trace the same business event arriving in two different objects. These are provenance fields, not a source-version ordering rule. Reserve these output column names in the ingestion contract. &lt;a href="https://docs.databricks.com/aws/en/ingestion/file-metadata-column" rel="noopener noreferrer"&gt;File metadata reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;With a supplied schema, Auto Loader defaults to schema evolution mode none. Without a rescued data column, unexpected fields are ignored. The explicit rescuedDataColumn option above retains them for review instead of silently losing them. It does not add them as typed business columns or automatically quarantine malformed JSON. &lt;a href="https://docs.databricks.com/aws/en/ingestion/cloud-object-storage/auto-loader/schema" rel="noopener noreferrer"&gt;Schema and rescued data reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Use a dedicated checkpoint for each ingestion workload and preserve it during ordinary recovery. Without its checkpoint, Auto Loader starts without its previous file-processing history. With the default cloudFiles.includeExistingFiles=true, existing eligible files under the source path are included again, which can append their rows to Bronze a second time. Source filters and file availability still apply; this cannot recover files already removed. &lt;a href="https://docs.databricks.com/aws/en/spark/api-options" rel="noopener noreferrer"&gt;Auto Loader options&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Watch checkpoint size as the workload grows; state accumulates. The production docs cover the operational details that go beyond this configuration.&lt;a href="https://docs.databricks.com/aws/en/ingestion/cloud-object-storage/auto-loader/production" rel="noopener noreferrer"&gt;Auto Loader production guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Deleting a bookmark does not make a book shorter. It mainly makes remembering your place more exciting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Business replay belongs in the destination contract
&lt;/h3&gt;

&lt;p&gt;Imagine that customer A is already stored at source version 10. A replay brings version 8. The ingestion may have done everything correctly. Silver must still prevent the state from moving backward.&lt;/p&gt;

&lt;p&gt;A possible current-state contract is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Higher source version: eligible to replace the older state.&lt;/li&gt;
&lt;li&gt;Same version, same payload: exact retry.&lt;/li&gt;
&lt;li&gt;Same version, different payload: conflict requiring a defined resolution path.&lt;/li&gt;
&lt;li&gt;Lower source version: older state; do not overwrite the newer state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complete implementation must enforce this contract across both the incoming batch and the existing target. It also needs explicit delete and concurrency rules; naming a column customer_id does not enforce uniqueness.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/pyspark/day_001_cdc" rel="noopener noreferrer"&gt;PySpark lab&lt;/a&gt; demonstrates one part of the problem: validating a finite batch, collapsing exact retries, rejecting internal conflicts, and selecting the highest version per normalized key. I covered that part in &lt;a href="https://medium.com/@jenniferevangelista.code/your-delta-merge-is-not-a-mind-reader-16e81b6c788b" rel="noopener noreferrer"&gt;Your Delta MERGE Is Not a Mind Reader&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a history requirement, keep the distinction sharp. Reducing a batch to its latest event can be correct for a current-state table and wrong for a consumer that needs the intermediate changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5hquah2bca75kfrto70.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5hquah2bca75kfrto70.png" width="800" height="534"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Original AI-generated editorial illustration. The returning delivery is a metaphor for replay, not an AWS service diagram.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When your ingestion design already handles file checkpoints and event identity, the next question is whether managed abstractions add anything. The short answer: they handle sequencing and SCD mechanics, but not the business specification that determines what a meaningful change is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed CDC still needs a business specification
&lt;/h3&gt;

&lt;p&gt;Lakeflow’s AUTO CDC APIs offer source sequencing and SCD processing. Their usefulness does not remove the need to define keys, meaningful change columns, delete behavior, and the desired history model. CVerify that these APIs are available in your runtime before building on them.&lt;a href="https://docs.databricks.com/aws/en/ldp/cdc" rel="noopener noreferrer"&gt;AUTO CDC reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For example, a technical ingestion timestamp should not create a new business version merely because the same event was delivered again. Conversely, a real change must not disappear because the comparison excluded an attribute the consumer cares about.&lt;/p&gt;

&lt;p&gt;Review a small set of explicit examples with the producer and consumer. Specifications become much easier to evaluate when someone can point at two contradictory records rather than another paragraph containing the word “robust.”&lt;/p&gt;

&lt;h3&gt;
  
  
  Quarantine needs an exit strategy
&lt;/h3&gt;

&lt;p&gt;Moving a bad row into a quarantine table is the beginning of resolution. Record the reason, a safe reference to the source, the rule version, and enough identity to connect the issue to its batch. Keep unnecessary personal data out of logs.&lt;/p&gt;

&lt;p&gt;Then define the return path. Does a correction arrive with a new source version? Is the original event amended? Who approves a replay, and how is the destination protected from duplication?&lt;/p&gt;

&lt;p&gt;Without that path, quarantine becomes a very well-organized place where data goes to wait indefinitely.&lt;/p&gt;

&lt;p&gt;Do not resolve a conflicting version by picking the alphabetically largest payload merely to keep the job green. That choice is a business rule in disguise. If no valid precedence rule exists, stopping with an actionable explanation can be more useful than quietly storing an arbitrary answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rehearse recovery with a small, unfriendly dataset
&lt;/h3&gt;

&lt;p&gt;Start with synthetic data: two customers, multiple versions of one customer, and an exact retry. Check the expected bronze records and silver state. Record input rows, distinct identities, applied versions, and rejected conflicts.&lt;/p&gt;

&lt;p&gt;Restart with the same checkpoint and inspect what actually happens. Deliver the same event in another object. Include an older version and a conflicting equal version. The target should follow the documented rules, and the conflict should reach the defined resolution path.&lt;/p&gt;

&lt;p&gt;Also test an interruption at a relevant point in your chosen write mechanism. If the pipeline writes several tables, do not presume that those writes form one atomic transaction. Recovery needs to explain how an incomplete run is detected and reconciled.&lt;/p&gt;

&lt;p&gt;Keep evidence from each rehearsal: input files, run identifiers, checkpoint used, expected and actual rows, and the reason each assertion passed or failed. A local transformation test, a service integration test, and an end-to-end recovery exercise answer different questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  A green job should still have something useful to say
&lt;/h3&gt;

&lt;p&gt;A process can finish successfully after receiving no fresh input. Monitor source freshness, processing delay, rejected records, and the consumer’s actual availability requirement alongside job completion.&lt;/p&gt;

&lt;p&gt;Every alert needs an owner and a documented response path. Decide how to distinguish an upstream delivery failure from a transformation defect. The right freshness threshold comes from the use case, not from a universal number borrowed from somebody else’s dashboard.&lt;/p&gt;

&lt;p&gt;The design holds up when you can explain, without looking at the code, what happens to a repeated event, an older version, and a conflict. Give those cases names and expected outcomes before they appear during an incident.&lt;/p&gt;

&lt;p&gt;Your future on-call self may never send a thank-you note. A quiet pager is a perfectly respectable substitute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; a reference design with synthetic examples. The companion batch-preparation lab was validated on local Spark 3.5.3. The Auto Loader configuration and recovery rehearsal were not executed against AWS or a Databricks workspace. Running the configuration requires authorized S3 and state-storage access, a compatible runtime, and catalog/schema and target-table permissions. Malformed input, deletes, concurrent writers and full history need their own implementation and tests.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;References checked on October 18, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>databricks</category>
      <category>apachespark</category>
    </item>
    <item>
      <title>Your Delta MERGE Is Not a Mind Reader</title>
      <dc:creator>Jennifer Evangelista</dc:creator>
      <pubDate>Sun, 18 Oct 2026 07:42:44 +0000</pubDate>
      <link>https://dev.to/jenniferevangelistade/your-delta-merge-is-not-a-mind-reader-2ppp</link>
      <guid>https://dev.to/jenniferevangelistade/your-delta-merge-is-not-a-mind-reader-2ppp</guid>
      <description>&lt;p&gt;&lt;strong&gt;A practical PySpark contract for duplicate events, conflicting versions, and deterministic updates.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpy746i09pyx4lln4mlg.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpy746i09pyx4lln4mlg.jpeg" alt="Three Spider-Man characters point at one another, illustrating multiple source rows competing to match one target row." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three source rows. One target row. Everyone insists they are the right match. Spider-Man pointing meme; image source: UOL / reproduction.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three records walk into a MERGE. All claim to represent the same customer. One carries yesterday’s address. Two carry today’s address. The target table has one row, and Delta can refuse to update it from more than one matching source row, even when those rows agree.&lt;/p&gt;

&lt;p&gt;If you have seen DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE, this article is for you.&lt;/p&gt;

&lt;p&gt;The engineering problem is less about persuading Delta to accept the input and more about defining what the input means. Which records describe the same entity? What makes a version newer? When is a duplicate harmless, and when is it evidence that the producer has contradicted itself?&lt;/p&gt;

&lt;p&gt;Those decisions belong in a data contract. A successful job is a poor substitute for one.&lt;/p&gt;
&lt;h3&gt;
  
  
  First, decide what “the same customer” means
&lt;/h3&gt;

&lt;p&gt;Consider this synthetic batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;sequence&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;
&lt;span class="nv"&gt;" a "&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;old&lt;/span&gt;
&lt;span class="nv"&gt;"A"&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;2&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
&lt;span class="nv"&gt;"A"&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;2&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this example, the business considers letter case and leading or trailing spaces irrelevant. We therefore normalize the key with upper(trim(key)). All three records identify A.&lt;/p&gt;

&lt;p&gt;This is a domain assumption, not a universal cleaning rule. If abc and ABC are different product codes in your system, converting them to uppercase would erase a meaningful distinction. Also, Spark's ordinary trim is not a complete Unicode normalization strategy. Be precise about the spaces and characters your contract accepts.&lt;/p&gt;

&lt;p&gt;Apply the same identity rule everywhere it matters: validation, aggregation, deduplication, and the eventual match condition. Grouping original keys and normalizing only inside ON can produce several supposedly unique groups that collapse into one match key.&lt;/p&gt;

&lt;p&gt;The records did not become duplicates because MERGE was feeling dramatic. The transformation exposed an identity rule that the earlier grouping had ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  “Latest” is a business rule with a sorting expression
&lt;/h3&gt;

&lt;p&gt;In this lab, sequence is a non-negative BIGINT supplied by the producer. It orders versions within each normalized key. Version 2 takes precedence over version 1, regardless of arrival order.&lt;/p&gt;

&lt;p&gt;Ingestion time is not automatically a safe substitute for a source sequence. A delayed version can arrive after a newer one. Sorting by arrival would then give the old state an undeserved promotion.&lt;/p&gt;

&lt;p&gt;If your source uses a timestamp, define what happens when timestamps tie. A source sequence can resolve the tie if it represents a meaningful ordering. A random identifier may make the result repeatable, but repeatable does not necessarily mean correct.&lt;/p&gt;

&lt;p&gt;For this example, the rule is deliberately strict: the same key and sequence must describe the same value. Two different values at the same position in the source history are a conflict. We reject the batch instead of inventing a winner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate an exact retry from a contradiction
&lt;/h3&gt;

&lt;p&gt;The implementation follows four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the required types and reject missing or blank keys and invalid sequences.&lt;/li&gt;
&lt;li&gt;Normalize the key according to the lab’s identity rule.&lt;/li&gt;
&lt;li&gt;Collapse exact repeats of key, sequence, and value, then reject multiple values for one key and sequence.&lt;/li&gt;
&lt;li&gt;Select the highest sequence for each key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The distinction matters. dropDuplicates(['key']) expresses which columns define duplication; it does not express a newest-version policy. Spark documents deduplication separately from the ordering you must define for your business rule. &lt;a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.dropDuplicates.html" rel="noopener noreferrer"&gt;PySpark reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;After conflict validation, the selection itself is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;prepared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_rank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;over&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_rank = 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_rank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, unique means the already-normalized and validated batch. Without that validation, row_number would silently pick one of the conflicting rows. A window function is very good at applying a rule; it cannot supply the missing business decision.&lt;/p&gt;

&lt;p&gt;Add a fourth row, A | 2 | other, and the batch is rejected: same key, same sequence, different value.&lt;/p&gt;

&lt;p&gt;The complete implementation is available in the &lt;a href="https://github.com/jenniferevangelista-de/data-engineering-lab/tree/main/pyspark/day_001_cdc" rel="noopener noreferrer"&gt;Data Engineering Lab&lt;/a&gt;, including the checks around this selection.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exact retry (same key, sequence, value): collapsed.&lt;/li&gt;
&lt;li&gt;Same key and sequence, different value: batch rejected.&lt;/li&gt;
&lt;li&gt;Higher sequence within the batch: wins.&lt;/li&gt;
&lt;li&gt;Older than target: ignored by the illustrated MERGE below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F90vctr317m54sy9hojq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F90vctr317m54sy9hojq0.png" alt="An engineer compares an old record and two identical newer records while a small robot holds back a contradictory record." width="800" height="534"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Exact retries can collapse. Contradictory versions need a decision. Original AI-generated editorial illustration.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  NULL deserves an explicit policy too
&lt;/h3&gt;

&lt;p&gt;The lab permits a NULL payload and does not interpret it as a delete. That choice is intentional. Some systems use null to mean an absent attribute, others use it to mean a cleared value, and others encode deletions in a separate operation field.&lt;/p&gt;

&lt;p&gt;A NULL value and a text value at the same key and sequence still conflict here. The implementation removes exact duplicate rows, then counts distinct rows per key and sequence. That avoids relying on an aggregate over distinct payload values that could ignore NULL.&lt;/p&gt;

&lt;p&gt;An empty DataFrame returns an empty result with the expected schema. A streaming DataFrame is rejected: the function accepts a finite batch and performs Spark actions during validation.&lt;/p&gt;

&lt;p&gt;That last detail has an operational consequence. Use a stable input snapshot. If the underlying source can change between validation and evaluation, the function can validate one view of the input and later evaluate another. The contract assumes the batch remains the same batch.&lt;/p&gt;
&lt;h3&gt;
  
  
  Run the example without a cloud account
&lt;/h3&gt;

&lt;p&gt;Clone the repository and run these commands from its root with Python 3.12 and Java 17 available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; pyspark/day_001_cdc/requirements.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; pyspark/day_001_cdc &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'test_*.py'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PySpark is pinned to 3.5.3. On Windows, the README explains the local Java and networking settings used for validation.&lt;/p&gt;

&lt;p&gt;Eight tests pass against a real local Spark JVM. They cover normalized keys, exact retries, conflicting versions, NULL handling, invalid inputs, empty input, repartitioning, and idempotence of the preparation.&lt;/p&gt;

&lt;h3&gt;
  
  
  A well-prepared batch can still overwrite the wrong state
&lt;/h3&gt;

&lt;p&gt;Suppose the target already contains version 10, and a later run receives version 8. The batch preparation correctly selects version 8 from that batch. The write must still prevent the persisted state from moving backward.&lt;/p&gt;

&lt;p&gt;An illustrative Delta adaptation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;MERGE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_current&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;prepared_updates&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This assumes a target with unique normalized keys and non-null sequence values. Equal versions with conflicting source and target payloads require an additional validation policy. The lab detects conflicts inside a batch, not between that batch and an already-persisted target.&lt;/p&gt;

&lt;p&gt;Runtime details matter as well. In DBR 15.4 LTS and below, duplicate-match detection considers the ON condition. In DBR 16.0 and above, it also considers the WHEN MATCHED conditions. Record your runtime and reproduce the actual case there. &lt;a href="https://docs.databricks.com/aws/en/delta/merge" rel="noopener noreferrer"&gt;MERGE documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preserve history when history is the requirement
&lt;/h3&gt;

&lt;p&gt;Keeping the last event per key is appropriate for some current-state workloads. It is not a general CDC algorithm. If a consumer needs every intermediate transition, discarding those transitions loses information even when the final state is correct.&lt;/p&gt;

&lt;p&gt;Before promoting this pattern, test a repeated batch, an older incoming version, a conflicting version already in the target, and the intended delete behavior. Check the target’s uniqueness assumption and your concurrency model. Measure shuffle and validation costs using representative data rather than treating a small correctness test as a scale claim.&lt;/p&gt;

&lt;p&gt;The useful question is not “How do I make MERGE stop complaining?” It is “Can I explain exactly which record should win, and why?”&lt;/p&gt;

&lt;p&gt;Once that answer is explicit, the code has a job it can actually perform. Mind reading can remain outside the service-level agreement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; synthetic data, validated on local Spark 3.5.3. The MERGE example is illustrative and was not run against Delta. It does not cover deletes, concurrent writers or SCD Type 2 history.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;References checked on October 18, 2026.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>apachespark</category>
      <category>databricks</category>
      <category>dataengineering</category>
      <category>pyspark</category>
    </item>
  </channel>
</rss>
