<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <link href="https://hattajr.github.io/feed.xml" rel="self"
    type="application/atom+xml" />
  <link href="https://hattajr.github.io" rel="alternate" type="text/html" />
  <updated>2026-10-29T01:18:15.242Z</updated>
  <id>https://hattajr.github.io/feed.xml</id>
  <title type="html">hattajr</title>
  <subtitle>hattajr&#039;s Arts&amp;Crafts</subtitle>
  <author>
    <name>Muhammad Hatta</name>
  </author>
  <entry>
    <title type="text">Grill, Slice, Ship: How I Work With Agents</title>
    <link href="https://hattajr.github.io/2026/10/28/grill-slice-ship.html"
      rel="alternate" type="text/html"
      title="Grill, Slice, Ship: How I Work With Agents" />
    <published>2026-10-28T00:00:00+00:00</published>
    <updated>2026-10-28T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2026/10/28/grill-slice-ship</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[On an average day I merge somewhere between 20 and 60 pull requests now (on my busiest day so far, GitHub counted 100 contributions), and I write almost none of the code in those PRs myself. The surprising part for me isn't the PR count. The surprising part is that the slow part of my day moved: implementation is fast, and nearly all of my time goes into planning before an agent is allowed to touch the codebase.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2026/10/28/grill-slice-ship.html">
      <![CDATA[
<header>
  <h1>Grill, Slice, Ship: How I Work With Agents</h1>
  <time class="meta" datetime="2026-10-28">Oct 28, 2026</time>
</header>
<p>On an average day I merge somewhere between 20 and 60 pull requests now (on my busiest day so far, GitHub counted 100 contributions), and I write almost none of the code in those PRs myself. The surprising part for me isn’t the PR count. The surprising part is that the slow part of my day moved: implementation is fast, and nearly all of my time goes into planning before an agent is allowed to touch the codebase.</p>
<p>In case that sounds made up, here’s my GitHub contribution graph for the last year. The dark squares on the right are the busy days, and the tooltip is sitting on the busiest one: 100 contributions on October 6th.</p>

<figure>

<img alt="GitHub contribution graph showing 2,360 contributions in the last year, with a tooltip reading 100 contributions on October 6th" src="/assets/2026-10-28-grill-slice-ship/contributions.png" width="804">
</figure>
<p>This post walks through the workflow I ended up with. The setup is a handful of small skills (a skill is just a prompt the agent loads for one specific job) chained together, plus one main Claude Code session that orchestrates everything. To be clear, I’m not claiming this is the correct way to work with agents. This is the way that works for me right now, and I fully expect to keep tweaking it.</p>
<div class="flow-diagram">
<svg class="h" viewBox="0 0 720 440" role="img"
  aria-labelledby="flow-title flow-desc">
<title id="flow-title">My agent workflow</title>
<desc id="flow-desc">Planning: dump, grill, slice, then GitHub
issues. Small ideas go through triage, which either grills them
or posts them as issues directly. The loop: /loop picks ready
issues, builds up to three in worktrees, opens a PR, merge-pr
merges it, and the next run starts again.</desc>
<defs>
  <marker id="ah" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#555"/>
  </marker>
  <marker id="ah-blue" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#2156a5"/>
  </marker>
  <marker id="ah-red" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#ba3925"/>
  </marker>
</defs>

<text class="band-lbl" x="20" y="30">
  PLANNING (MOST OF MY TIME)</text>

<rect class="box" x="20" y="44" width="140" height="56" rx="6"/>
<text class="t" x="90" y="68" text-anchor="middle">dump</text>
<text class="s" x="90" y="88" text-anchor="middle">
  everything I have</text>

<rect class="box hot" x="185" y="44" width="140" height="56"
  rx="6"/>
<text class="t" x="255" y="68" text-anchor="middle">grill-me</text>
<text class="s" x="255" y="88" text-anchor="middle">
  cut to the minimum</text>

<rect class="box" x="350" y="44" width="140" height="56" rx="6"/>
<text class="t" x="420" y="68" text-anchor="middle">
  to-tickets</text>
<text class="s" x="420" y="88" text-anchor="middle">
  vertical slices</text>

<rect class="box gh" x="530" y="44" width="170" height="56"
  rx="6"/>
<text class="t" x="615" y="68" text-anchor="middle">
  GitHub issues</text>
<text class="s" x="615" y="88" text-anchor="middle">
  parent + sub-issues</text>

<path class="arrow" d="M160 72 H181" marker-end="url(#ah)"/>
<path class="arrow" d="M325 72 H346" marker-end="url(#ah)"/>
<path class="arrow" d="M490 72 H526" marker-end="url(#ah)"/>

<rect class="box" x="185" y="150" width="140" height="56"
  rx="6"/>
<text class="t" x="255" y="174" text-anchor="middle">
  small idea</text>
<text class="s" x="255" y="194" text-anchor="middle">
  chat or phone</text>

<rect class="box" x="350" y="150" width="140" height="56"
  rx="6"/>
<text class="t" x="420" y="174" text-anchor="middle">triage</text>
<text class="s" x="420" y="194" text-anchor="middle">
  grill it or post it?</text>

<path class="arrow" d="M325 178 H346" marker-end="url(#ah)"/>
<path class="arrow" d="M490 178 H600 V104"
  marker-end="url(#ah)"/>
<text class="lbl" x="500" y="198">small and clear</text>
<path class="arrow maybe" d="M385 150 L292 104"
  marker-end="url(#ah-red)"/>
<text class="lbl" x="308" y="130" text-anchor="end">
  needs grilling</text>

<rect class="band" x="8" y="244" width="704" height="188"
  rx="8"/>
<text class="band-lbl" x="20" y="266">
  THE LOOP (RUNS ON ITS OWN)</text>

<path class="arrow flow" d="M665 100 V286"
  marker-end="url(#ah-blue)"/>
<text class="lbl" x="657" y="222" text-anchor="end">
  ready-for-agent</text>

<rect class="box" x="530" y="290" width="170" height="56"
  rx="6"/>
<text class="t" x="615" y="314" text-anchor="middle">/loop</text>
<text class="s" x="615" y="334" text-anchor="middle">
  pick a safe batch (up to 3)</text>

<rect class="box" x="362" y="278" width="140" height="56"
  rx="6"/>
<rect class="box" x="356" y="284" width="140" height="56"
  rx="6"/>
<rect class="box" x="350" y="290" width="140" height="56"
  rx="6"/>
<text class="t" x="420" y="314" text-anchor="middle">
  implement-issues</text>
<text class="s" x="420" y="334" text-anchor="middle">
  one worktree each</text>

<rect class="box" x="185" y="290" width="140" height="56"
  rx="6"/>
<text class="t" x="255" y="314" text-anchor="middle">
  pull request</text>
<text class="s" x="255" y="334" text-anchor="middle">
  one per batch</text>

<rect class="box" x="20" y="290" width="140" height="56" rx="6"/>
<text class="t" x="90" y="314" text-anchor="middle">merge-pr</text>
<text class="s" x="90" y="334" text-anchor="middle">
  review, merge, clean up</text>

<path class="arrow flow" d="M530 318 H506"
  marker-end="url(#ah-blue)"/>
<path class="arrow flow" d="M350 318 H329"
  marker-end="url(#ah-blue)"/>
<path class="arrow flow" d="M185 318 H164"
  marker-end="url(#ah-blue)"/>

<rect class="harness" x="185" y="358" width="317" height="24"
  rx="4"/>
<text class="lbl" x="343" y="374" text-anchor="middle">
  harness: tests, lint, types, framework skills</text>

<path class="arrow flow" d="M90 346 V412 H640 V350"
  marker-end="url(#ah-blue)"/>
<text class="s" x="365" y="401" text-anchor="middle">
  next run picks up whatever just got unblocked</text>
</svg>

<svg class="v" viewBox="0 0 360 740" aria-hidden="true">
<defs>
  <marker id="vah" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#555"/>
  </marker>
  <marker id="vah-blue" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#2156a5"/>
  </marker>
  <marker id="vah-red" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#ba3925"/>
  </marker>
</defs>

<text class="band-lbl" x="12" y="24">PLANNING (MOST OF MY TIME)</text>

<rect class="box" x="20" y="36" width="170" height="50" rx="6"/>
<text class="t" x="105" y="57" text-anchor="middle">dump</text>
<text class="s" x="105" y="76" text-anchor="middle">
  everything I have</text>

<rect class="box hot" x="20" y="116" width="170" height="50"
  rx="6"/>
<text class="t" x="105" y="137" text-anchor="middle">grill-me</text>
<text class="s" x="105" y="156" text-anchor="middle">
  cut to the minimum</text>

<rect class="box" x="20" y="196" width="170" height="50" rx="6"/>
<text class="t" x="105" y="217" text-anchor="middle">
  to-tickets</text>
<text class="s" x="105" y="236" text-anchor="middle">
  vertical slices</text>

<rect class="box gh" x="20" y="276" width="170" height="50"
  rx="6"/>
<text class="t" x="105" y="297" text-anchor="middle">
  GitHub issues</text>
<text class="s" x="105" y="316" text-anchor="middle">
  parent + sub-issues</text>

<path class="arrow" d="M105 86 V112" marker-end="url(#vah)"/>
<path class="arrow" d="M105 166 V192" marker-end="url(#vah)"/>
<path class="arrow" d="M105 246 V272" marker-end="url(#vah)"/>

<rect class="box" x="215" y="116" width="135" height="50"
  rx="6"/>
<text class="t" x="282" y="137" text-anchor="middle">
  small idea</text>
<text class="s" x="282" y="156" text-anchor="middle">
  chat or phone</text>

<rect class="box" x="215" y="196" width="135" height="50"
  rx="6"/>
<text class="t" x="282" y="217" text-anchor="middle">triage</text>
<text class="s" x="282" y="236" text-anchor="middle">
  grill it or post it?</text>

<path class="arrow" d="M282 166 V192" marker-end="url(#vah)"/>
<path class="arrow" d="M282 246 V301 H194" marker-end="url(#vah)"/>
<path class="arrow maybe" d="M215 206 L194 156"
  marker-end="url(#vah-red)"/>

<rect class="band" x="4" y="350" width="352" height="386"
  rx="8"/>
<text class="band-lbl" x="348" y="374" text-anchor="end">
  THE LOOP (RUNS ON ITS OWN)</text>

<path class="arrow flow" d="M105 326 V392"
  marker-end="url(#vah-blue)"/>
<text class="lbl" x="113" y="344">ready-for-agent</text>

<rect class="box" x="20" y="396" width="170" height="50" rx="6"/>
<text class="t" x="105" y="417" text-anchor="middle">/loop</text>
<text class="s" x="105" y="436" text-anchor="middle">
  pick a safe batch (up to 3)</text>

<rect class="box" x="28" y="468" width="170" height="50" rx="6"/>
<rect class="box" x="24" y="472" width="170" height="50" rx="6"/>
<rect class="box" x="20" y="476" width="170" height="50" rx="6"/>
<text class="t" x="105" y="497" text-anchor="middle">
  implement-issues</text>
<text class="s" x="105" y="516" text-anchor="middle">
  one worktree each</text>

<rect class="box" x="20" y="556" width="170" height="50" rx="6"/>
<text class="t" x="105" y="577" text-anchor="middle">
  pull request</text>
<text class="s" x="105" y="596" text-anchor="middle">
  one per batch</text>

<rect class="box" x="20" y="636" width="170" height="50" rx="6"/>
<text class="t" x="105" y="657" text-anchor="middle">merge-pr</text>
<text class="s" x="105" y="676" text-anchor="middle">
  review, merge, clean up</text>

<path class="arrow flow" d="M105 446 V464"
  marker-end="url(#vah-blue)"/>
<path class="arrow flow" d="M105 526 V552"
  marker-end="url(#vah-blue)"/>
<path class="arrow flow" d="M105 606 V632"
  marker-end="url(#vah-blue)"/>

<rect class="harness" x="222" y="476" width="128" height="130"
  rx="4"/>
<text class="lbl" x="286" y="502" text-anchor="middle">harness:</text>
<text class="lbl" x="286" y="524" text-anchor="middle">tests</text>
<text class="lbl" x="286" y="542" text-anchor="middle">lint</text>
<text class="lbl" x="286" y="560" text-anchor="middle">types</text>
<text class="lbl" x="286" y="578" text-anchor="middle">
  framework skills</text>

<path class="arrow flow" d="M190 661 H208 V421 H194"
  marker-end="url(#vah-blue)"/>
<text class="s" x="180" y="714" text-anchor="middle">
  next run picks up whatever just got unblocked</text>
</svg>
</div>
<section id="Dump-Then-Get-Grilled">

<h2><a href="#Dump-Then-Get-Grilled">Dump, Then Get Grilled</a></h2>
<p>The process starts messy on purpose. I open a session and throw everything I have at the agent: what I want, why I want it, half-remembered links, decisions I’ve already made, things I’m unsure about, etc. I don’t try to write a specification at this stage because I don’t <em>know</em> the specification yet.</p>
<p>Then I ask the agent to grill me. My <code>grill-me</code> skill<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a> treats the plan as a tree of decisions, where each answer opens up the questions that depend on it. The interview happens in rounds. Each round contains all the questions the agent can ask without guessing at my other answers, with a recommended answer attached to each question. I answer, the agent recomputes the tree, and we repeat until no decision is left open.</p>
<p>The thing I do most in this phase is cut scope. I push toward the minimum in each round: what’s the smallest version of this application that’s still worth having? Which feature can wait for later? Agents are very happy to build every feature you mention, so if I don’t cut the scope here, nobody will.</p>
<p><strong>This is the phase that takes the longest, and it should.</strong> The agent is good at finding gaps (an edge case I didn’t consider, two decisions that contradict each other, etc.), but deciding what to do about those gaps is my responsibility, and I can’t delegate that part. If I rush the grilling, I pay for it later in PRs that build the wrong thing very quickly.</p>
<p>A while ago I read a post about coding with AI that stuck with me. The gist: with AI it’s easy to get things right, but it’s just as easy to get them wrong, and a wrong decision doesn’t stay small. Everything downstream gets built on top of it, so by the time you notice, going back to the point where the decision was made costs a lot. And agents build on a bad decision just as fast as on a good one. That’s why I want the grilling and planning phase to be as thorough as I can make it. For a big project, the grilling alone sometimes takes days, or even weeks.</p>
</section>
<section id="Slicing-It-Vertically">

<h2><a href="#Slicing-It-Vertically">Slicing It Vertically</a></h2>
<p>After the grilling, another skill, <code>to-tickets</code>, cuts the whole discussion into vertical slices.<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a></p>
<p>The common way to split work is by layer: one task for the database, one for the backend, one for the frontend, one for tests. That looks tidy, but each layer is waiting on the layer below. The frontend can’t start until the backend exists, and you can’t really test the backend until something calls the endpoints. So you end up with a chain of dependent tasks, and a chain doesn’t parallelize.</p>
<p>A vertical slice goes through all of the layers for one narrow piece of behavior. For example, say I’m building a bookmarks app. Instead of “all the tables, then all the endpoints, then all the pages”, the slices look like “a user can save a bookmark” (the table, the endpoint, the form and a test), then “a user can tag a bookmark”, then “a user can search their bookmarks”. The backend and frontend for a slice ship together because they’re one flow, so each slice is testable by itself, and most slices don’t block each other. If a slice really does depend on another slice, the dependency is written down explicitly instead of being hidden in the order of a list.</p>
<div class="flow-diagram slice-diagram">
<svg viewBox="24 30 428 280" role="img"
  aria-labelledby="slice-title slice-desc">
<title id="slice-title">Vertical slices</title>
<desc id="slice-desc">Slices of the app drawn like slices of a
layer cake. Each slice, such as save bookmark or tag bookmark,
cuts through the UI, API, DB and test layers, and each slice
goes to its own agent at the same time.</desc>
<defs>
  <marker id="sah" viewBox="0 0 10 10" refX="9" refY="5"
    markerWidth="7" markerHeight="7"
    orient="auto-start-reverse">
    <path d="M0 0 L10 5 L0 10 z" fill="#2156a5"/>
  </marker>
</defs>
<text class="layer" x="58" y="88" text-anchor="end">UI</text>
<text class="layer" x="58" y="116" text-anchor="end">API</text>
<text class="layer" x="58" y="144" text-anchor="end">DB</text>
<text class="layer" x="58" y="172" text-anchor="end">test</text>
<g class="slice ">
<polygon points="70,70 90,56 174,56 154,70" fill="#fbeeea" class="face"/>
<polygon points="70,70 154,70 154,98 70,98" fill="#f6d9d2" class="face"/>
<polygon points="154,70 174,56 174,84 154,98" fill="#e7bdb3" class="face"/>
<text class="s" x="112.0" y="89" text-anchor="middle">form</text>
<polygon points="70,98 154,98 154,126 70,126" fill="#ecae9f" class="face"/>
<polygon points="154,98 174,84 174,112 154,126" fill="#d8907f" class="face"/>
<text class="s" x="112.0" y="117" text-anchor="middle">endpoint</text>
<polygon points="70,126 154,126 154,154 70,154" fill="#cd7462" class="face"/>
<polygon points="154,126 174,112 174,140 154,154" fill="#b25a49" class="face"/>
<text class="s" style="fill:#fff" x="112.0" y="145" text-anchor="middle">table</text>
<polygon points="70,154 154,154 154,182 70,182" fill="#a9c0e0" class="face"/>
<polygon points="154,154 174,140 174,168 154,182" fill="#8aa5cc" class="face"/>
<text class="s" x="112.0" y="173" text-anchor="middle">test</text>
<text class="t sm" x="122.0" y="46" text-anchor="middle">save bookmark</text>
<path class="arrow flow" d="M112.0 188 V218" marker-end="url(#sah)"/>
<rect class="box" x="70" y="222" width="84" height="30" rx="6"/>
<text class="t sm" x="112.0" y="242" text-anchor="middle">agent 1</text>
</g>
<g class="slice ">
<polygon points="190,70 210,56 294,56 274,70" fill="#fbeeea" class="face"/>
<polygon points="190,70 274,70 274,98 190,98" fill="#f6d9d2" class="face"/>
<polygon points="274,70 294,56 294,84 274,98" fill="#e7bdb3" class="face"/>
<text class="s" x="232.0" y="89" text-anchor="middle">form</text>
<polygon points="190,98 274,98 274,126 190,126" fill="#ecae9f" class="face"/>
<polygon points="274,98 294,84 294,112 274,126" fill="#d8907f" class="face"/>
<text class="s" x="232.0" y="117" text-anchor="middle">endpoint</text>
<polygon points="190,126 274,126 274,154 190,154" fill="#cd7462" class="face"/>
<polygon points="274,126 294,112 294,140 274,154" fill="#b25a49" class="face"/>
<text class="s" style="fill:#fff" x="232.0" y="145" text-anchor="middle">table</text>
<polygon points="190,154 274,154 274,182 190,182" fill="#a9c0e0" class="face"/>
<polygon points="274,154 294,140 294,168 274,182" fill="#8aa5cc" class="face"/>
<text class="s" x="232.0" y="173" text-anchor="middle">test</text>
<text class="t sm" x="242.0" y="46" text-anchor="middle">tag bookmark</text>
<path class="arrow flow" d="M232.0 188 V218" marker-end="url(#sah)"/>
<rect class="box" x="190" y="222" width="84" height="30" rx="6"/>
<text class="t sm" x="232.0" y="242" text-anchor="middle">agent 2</text>
</g>
<g class="slice faded">
<polygon points="340,70 360,56 444,56 424,70" fill="#fbeeea" class="face"/>
<polygon points="340,70 424,70 424,98 340,98" fill="#f6d9d2" class="face"/>
<polygon points="424,70 444,56 444,84 424,98" fill="#e7bdb3" class="face"/>
<text class="s" x="382.0" y="89" text-anchor="middle">form</text>
<polygon points="340,98 424,98 424,126 340,126" fill="#ecae9f" class="face"/>
<polygon points="424,98 444,84 444,112 424,126" fill="#d8907f" class="face"/>
<text class="s" x="382.0" y="117" text-anchor="middle">endpoint</text>
<polygon points="340,126 424,126 424,154 340,154" fill="#cd7462" class="face"/>
<polygon points="424,126 444,112 444,140 424,154" fill="#b25a49" class="face"/>
<text class="s" style="fill:#fff" x="382.0" y="145" text-anchor="middle">table</text>
<polygon points="340,154 424,154 424,182 340,182" fill="#a9c0e0" class="face"/>
<polygon points="424,154 444,140 444,168 424,182" fill="#8aa5cc" class="face"/>
<text class="s" x="382.0" y="173" text-anchor="middle">test</text>
<text class="t sm" x="392.0" y="46" text-anchor="middle">search, etc.</text>
<path class="arrow flow" d="M382.0 188 V218" marker-end="url(#sah)"/>
<rect class="box" x="340" y="222" width="84" height="30" rx="6"/>
<text class="t sm" x="382.0" y="242" text-anchor="middle">agent n</text>
</g>
<text class="dots" x="317" y="134" text-anchor="middle">...</text>
<text class="s" x="250" y="300" text-anchor="middle">
  own worktree, own tests, all at the same time</text>
</svg>
</div>
<p>I create the repository first, then the slices get posted as GitHub issues. The idea turns into one parent issue, with each slice as a sub-issue under that parent and acceptance criteria and blockers linked. Any fully specified issue gets a <code>ready-for-agent</code> label, and that label is the gate: nothing gets implemented without the label.</p>
<p>I want to stress this part, because it’s the one I’d keep even if I threw out everything else: <em>the GitHub issue is the record of every decision.</em> What the grilling settled, the brief, the acceptance criteria, which run claimed it, the PR that implemented it, the follow-up somebody found during review, etc. It all lands on the issue body or in its comment history. So I don’t keep decision state anywhere else. No design docs folder, no <code>DECISIONS.md</code>, no notes file the agent has to be told about. An agent that picks up an issue (or comes back to it three PRs later) just reads the issue and has the whole story, and it can iterate from there.</p>
<p>The issues <em>are</em> the plan, and they’re also the memory. I’m not the first person to lean on GitHub issues this way, either. Simon Willison wrote about using them as his notebook in <a href="https://simonwillison.net/2025/May/26/notes/">GitHub issues for notes</a>: Markdown, links between issues across repos, search, and an API, all for free. It turns out those are exactly the features an agent needs too.</p>
<p>PRs hold context too (a bug the agent tripped over while building, a spot where it had to deviate from the brief, a review finding), but a PR is one attempt, and once it merges nobody goes back to it. One issue can also end up with more than one PR, so decisions spread across them would be hard to find. So the rule is simple: anything a PR finds that future work needs goes back to an issue. A changed decision goes into the original issue, and a new bug gets its own issue. Everything else on the PR is just evidence for the review.</p>
</section>
<section id="Building-and-Merging">

<h2><a href="#Building-and-Merging">Building and Merging</a></h2>
<p>The <code>implement-issues</code> skill looks for ready issues, picks a batch that’s safe to run together (up to three at a time), and claims those issues so two runs never grab the same work. Each issue gets built in a separate git worktree on a separate branch, so the agents don’t step on each other’s files, and the batch goes up as a PR. If I point the skill at a parent issue, the agent works through the children in waves ordered by their blockers. Each wave is merged before the next wave starts, so later slices build on what’s actually in <code>main</code>.</p>
<p>Merging is a separate skill too. <code>merge-pr</code> reviews the open PRs, runs the checks, merges the safe ones and cleans up the branches and worktrees afterward. If a PR needs a follow-up, the merge agent routes the follow-up back instead of quietly merging.</p>
<p>I only talk to the main session. The main session hands the work out to the other agents and brings the results back to me.</p>
</section>
<section id="The-Harness-Does-the-Heavy-Lifting">

<h2><a href="#The-Harness-Does-the-Heavy-Lifting">The Harness Does the Heavy Lifting</a></h2>
<p>One might ask: how do 60 PRs a day not turn into 60 PRs of garbage? The answer is the harness, and I want to stress this one, because without it none of the above would work.</p>
<p>Tests are part of the harness, but only part. Every change also has to pass my code quality checks, such as linting, formatting and type checking, with zero errors before the agent is allowed to call the work done. And there’s a second layer that tells the agent <em>how</em> to build things: skills for the frameworks I use (FastAPI, HTMX, Alpine.js, Polars, etc.) that spell out my conventions, so the agent follows the way I build instead of making up its own way in each PR. The agents are fast, but the harness is what lets me trust that fast output enough to merge it.</p>
</section>
<section id="Keep-It-Looping">

<h2><a href="#Keep-It-Looping">Keep It Looping</a></h2>
<p>One batch of three is nice, but I don’t want to come back and type “implement the next ones” after each merge. So the main session runs <code>implement-issues</code> inside <code>/loop</code>:</p>

<figure class="code-block">


<pre><code><span class="line">/loop /implement-issues</span></code></pre>

</figure>
<p>The <code>/loop</code> command just runs a prompt repeatedly. You can give it a fixed interval such as <code>/loop 10m ...</code>, or leave the interval off and let the agent decide how long to wait. On each run, the agent reads the open issues, finds the <code>ready-for-agent</code> issues with no open blockers, and implements the next safe batch. After a batch merges, the slices waiting on that batch are unblocked and the next run picks them up. If nothing is ready, the loop waits and checks again later.</p>
<p>Small ideas skip most of this process. Halfway through the day I’ll think of something like a button in the wrong place, and I just tell the main session. The <code>triage</code> skill decides whether the idea needs grilling or whether the change is small and clear enough to post as an issue right away. If the issue goes straight in, the next run of the loop builds the change, opens a PR and merges it without any further input from me.</p>
<p>Sometimes I’m not even at my desk when an idea shows up. In that case I grab my phone, open the GitHub app, and create an issue with just a title and a very short description. That’s it. Later I revisit those issues with <code>triage</code>, which fills in the brief, grills me if the idea needs it, and only then adds the <code>ready-for-agent</code> label. Until that happens the loop ignores the issue, so a half-baked idea from my phone never gets built by accident.</p>
<p>So my job is to keep the issue list full of well-grilled issues, and the loop’s job is to keep emptying the list. That’s the whole setup for now.</p>
</section>
<section role="doc-endnotes">
<hr>
<ol>
<li id="fn1">
<p><code>grill-me</code> comes from <a href="https://github.com/mattpocock/skills/tree/main/skills/productivity/grill-me">Matt Pocock’s skills</a>.<a href="#fnref1" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn2">
<p><code>to-tickets</code> and <code>triage</code> started as adaptations of Matt Pocock’s skills too, and the vertical-slice idea comes straight from there. I didn’t come up with it.<a href="#fnref2" role="doc-backlink">↩︎</a></p>
</li>
</ol>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Watching My Heart Beat</title>
    <link href="https://hattajr.github.io/2026/08/28/ecg-live-heartbeat.html"
      rel="alternate" type="text/html" title="Watching My Heart Beat" />
    <published>2026-08-28T00:00:00+00:00</published>
    <updated>2026-08-28T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2026/08/28/ecg-live-heartbeat</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[At Wellysis, where I work, one of our products is an FDA-cleared single-lead ECG device. So I thought, why not use it to animate my own heartbeat? You've seen those pulsing heart animations that just loop on a timer. This is that kind of thing, but every beat comes from a real recording, so it's a lot more precise. I wore it for a whole day, logged what I was doing in the app's diary as I went, and then used the diary to cut out one-minute samples from four different moments. The animation plays along with those.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2026/08/28/ecg-live-heartbeat.html">
      <![CDATA[
<header>
  <h1>Watching My Heart Beat</h1>
  <time class="meta" datetime="2026-08-28">Aug 28, 2026</time>
</header>
<iframe
  title="ECG Live comparison of real recordings"
  src="/assets/ecglive/index.html"
  width="100%"
  height="1380"
  class="ecg-live-frame"
  loading="lazy"
></iframe>
<p>At <a href="https://www.wellysis.com/">Wellysis</a>, where I work, one of our products is an FDA-cleared single-lead ECG device. So I thought, why not use it to animate my own heartbeat? You’ve seen those pulsing heart animations that just loop on a timer. This is that kind of thing, but every beat comes from a real recording, so it’s a lot more precise. I wore it for a whole day, logged what I was doing in the app’s diary as I went, and then used the diary to cut out one-minute samples from four different moments. The animation plays along with those.</p>
<p>Click <strong><strong>Start</strong></strong> to play all four. Each row is one 60-second recording, and the heart next to it beats in time with that trace. Peacefully sleeping, my heart was at ~59 BPM, fully caffeinated and technically working at ~75 BPM, a 50 m run to make the zebra crossing at ~140 BPM, and playing Dota 2 on the SEA server at ~127 BPM.</p>
<p>Yes, a game of DOTA 2 in SEA server gets my heart to ~127 BPM, only ~13 BPM less than actually sprinting. If you’ve ever played DOTA 2 on SEA, you already know why.</p>
<p>The heart is timed by a small QRS detector that runs in your browser. It finds the R peaks in the trace and schedules every contraction off them, so a faster heart rate makes the animation pump faster too.</p>
<p>To be clear, this is a toy and not a clinical analysis. If you actually want to know how your heart is doing, that’s the job of the real analysis and a cardiologist. A single ECG lead also can’t tell you valve timing or blood flow, so those parts of the animation are an educated guess built on top of the beat timing.</p>
]]>
    </content>
  </entry>
  <entry>
    <title
      type="text">From 1.7s to 75ms: How Small Changes Sped Up Our Deltalake Reads</title>
    <link
      href="https://hattajr.github.io/2026/06/10/improve-read-deltatable-polars.html"
      rel="alternate" type="text/html"
      title="From 1.7s to 75ms: How Small Changes Sped Up Our Deltalake Reads" />
    <published>2026-06-10T00:00:00+00:00</published>
    <updated>2026-06-10T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2026/06/10/improve-read-deltatable-polars</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[Reading a 16 TB Delta Lake table with Polars, and what made it 23× faster.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2026/06/10/improve-read-deltatable-polars.html">
      <![CDATA[
<header>
  <h1>From 1.7s to 75ms: How Small Changes Sped Up Our Deltalake Reads</h1>
  <time class="meta" datetime="2026-06-10">Jun 10, 2026</time>
</header>
<p><em>Reading a 16 TB Delta Lake table with Polars, and what made it 23× faster.</em></p>

<figure class="blockquote">
<blockquote><p><strong><strong>Update, 2026-06-12:</strong></strong> I published this thinking the speedup was all in the code, but I never actually checked whether MinIO’s OS page cache or the disk layout was doing the real work. The same storage does ~14 seconds cold, and those “fast” numbers might just be warm cache hits on two over-full spindles. I’ll write the full storage autopsy as a separate post – treat these benchmarks as optimistic until then.</p>
</blockquote>

</figure>
<p>We read the same Delta Lake table over and over: a training loop pulling batches to feed a model, and a FastAPI route serving one series per request. The data lives on S3, about 16 TB of it, spread across 56,827 files, roughly 200 million rows, partitioned by series. The query is dull. We filter to one series (a <code>uid</code>) and hand back 500 rows. The obvious way to write it:</p>

<figure class="code-block">


<pre><code><span class="line">df = (</span>
<span class="line">    pl.scan_delta(delta_path)</span>
<span class="line">    .<span class="hl-built_in">filter</span>(pl.col(<span class="hl-string">&quot;uid&quot;</span>) == uid)</span>
<span class="line">    .head(<span class="hl-number">500</span>)</span>
<span class="line">    .collect()</span>
<span class="line">)</span></code></pre>

</figure>
<p>Each call took 1700 milliseconds. That’s fine if you run it once. But we don’t run it once: we run it in a loop, thousands of times, and 1700 milliseconds per pull turns a batch job or a request handler into something that crawls.</p>
<p>The reason is in the call itself. We pass <code>scan_delta</code> a <em>path</em>, and a path means “go figure out the current state of this table from scratch.” Every iteration rescans the Delta path: it re-reads the transaction log from S3 and re-enumerates all 56,827 files, before it touches a single row we actually asked for. We were rebuilding the entire scan from the path on every pass through the loop.</p>
<p>Rather than assume S3 is just slow, we measured where the 1700 milliseconds actually went.</p>
<p>Almost none of it was the query. Of the 1700 milliseconds, about 1100 milliseconds was Delta re-reading and replaying its transaction log from S3. About 260 milliseconds more was enumerating all 56,827 files to build the query plan. Around 300 milliseconds was reading the actual columns, most of which we didn’t even want. The query itself was about 75 milliseconds.</p>
<p>So 95% of the time was setup, and the setup was identical every time. The transaction log doesn’t change between two requests a second apart, and neither does the file list. We were deriving the same facts on every call, throwing them away, and deriving them again.</p>
<p>There were three redundant pieces, and we removed them one at a time.</p>
<p>The first is the log replay, the cost of passing a path. Load the table once and pass the loaded object around instead, and the log gets read a single time in the life of the process rather than once per query. That’s the 1100 milliseconds gone.</p>
<p>The second is the file enumeration. Even with the table loaded, building the scan walks all 56,827 files to decide what the plan looks like. But that walk produces the same answer every time too, so you do it once: build the scanner once, and per request only change the filter and collect.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">from</span> deltalake <span class="hl-keyword">import</span> DeltaTable</span>
<span class="line"><span class="hl-keyword">import</span> polars <span class="hl-keyword">as</span> pl</span>
<span class="line"></span>
<span class="line"><span class="hl-comment"># 1. Load the snapshot once: one transaction-log read</span></span>
<span class="line"><span class="hl-comment">#    from S3, for the whole process.</span></span>
<span class="line">dt = DeltaTable(delta_path, storage_options=storage_options)</span>
<span class="line"></span>
<span class="line"><span class="hl-comment"># 2. Freeze the scan once: file enumeration happens here,</span></span>
<span class="line"><span class="hl-comment">#    and the file list is baked into this LazyFrame.</span></span>
<span class="line"><span class="hl-comment">#    Project only the columns you need while you&#x27;re at it.</span></span>
<span class="line">base_lf = pl.scan_delta(dt).select(</span>
<span class="line">    <span class="hl-string">&quot;uid&quot;</span>, <span class="hl-string">&quot;partition_key_int&quot;</span>, <span class="hl-string">&quot;start_time&quot;</span></span>
<span class="line">)</span>
<span class="line"></span>
<span class="line"><span class="hl-comment"># 3. Loop: per iteration only swap the filter and collect.</span></span>
<span class="line"><span class="hl-comment">#    No re-read, no re-enumerate.</span></span>
<span class="line"><span class="hl-keyword">for</span> uid <span class="hl-keyword">in</span> uids:</span>
<span class="line">    df = (</span>
<span class="line">        base_lf.<span class="hl-built_in">filter</span>(pl.col(<span class="hl-string">&quot;uid&quot;</span>) == uid)</span>
<span class="line">        .head(<span class="hl-number">500</span>)</span>
<span class="line">        .collect()  <span class="hl-comment"># ~75 ms each</span></span>
<span class="line">    )</span>
<span class="line">    ...</span></code></pre>

</figure>
<p>The third is reading columns you’re not going to return. This is projection pushdown: when you <code>.select()</code> only the columns you need, Polars pushes that projection down into the Parquet reader so the wide columns are never fetched from S3 at all. Our wide signal columns are thousands of times larger than the scalar ones, and for these queries we don’t want them, so the pushdown drops nearly all the payload bytes. On the raw <code>scan_delta(path)</code> it looked negligible; it shaved under a fifth off a number the log read already dominated, so it’s easy to dismiss. But the log read was masking it. Once the log read and the enumeration are gone, projection is the largest lever left: it takes the reused read from ~380 milliseconds down to ~75, about 5×.</p>
<p>Removing them in order, the cost drops each time. Measured against the live table, same query, warm process:</p>
<table>
<tr>
<th>Change</th>
<th>What it removes</th>
<th>Time per query</th>
</tr>
<tr>
<td>Starting point: <code>scan_delta(path)</code>, all columns, on every call</td>
<td>nothing yet</td>
<td>~1750 milliseconds</td>
</tr>
<tr>
<td>Reuse the loaded snapshot (pass the <code>DeltaTable</code>)</td>
<td>log replay, ~1100 milliseconds</td>
<td>~640 milliseconds</td>
</tr>
<tr>
<td>Reuse the built scan (build once, swap the filter)</td>
<td>file enumeration, ~260 milliseconds</td>
<td>~380 milliseconds</td>
</tr>
<tr>
<td>Project only the scalar columns</td>
<td>wide column bytes, ~305 milliseconds</td>
<td>~75 milliseconds floor</td>
</tr>
</table>
<p>(Means over repeated warm reads against the live table; S3 latency varies run to run, so treat these as representative, not exact.)</p>
<p>For contrast, once you’re projecting the scalar columns, rebuilding the scan on every call sits steady at ~300 milliseconds while build-once holds at ~75: that ~225 milliseconds gap is the file-enumeration tax over 56,827 files, paid again and again.</p>
<p>A few things surprised us.</p>
<p>Asking for fewer rows doesn’t help. <code>head(1)</code> costs the same as <code>head(500)</code>. The floor is per-file and per-column, not per-row, so once you’ve paid to open the right file the rows are nearly free.</p>
<p>Better partition pruning doesn’t help either, which was the counterintuitive one. Our first instinct was to prune harder. But we were already pruning perfectly, 1 file out of 56,827, and pruning happens <em>after</em> the log is replayed, so no amount of it can shrink the thing that was actually slow.</p>
<p>And the 75 milliseconds doesn’t depend on <em>which</em> query you run. Different series, different filter shapes, they all land around 75 milliseconds, because the expensive work is the setup and the setup is the same regardless. That’s what makes the whole thing safe to build a service on.</p>
<p>This points at how you actually serve it, and at the one condition the whole approach depends on: it only works if the process stays alive and reuses the snapshot across many requests. We query the Delta table directly from Polars inside a long-lived FastAPI service, and the trick is <em>where</em> the setup happens: once, at startup, instead of once per request.</p>
<p>Call this from a one-off script that loads the table, runs a single query, and exits, and none of it helps. That script pays the full ~1700 milliseconds every time, because there is no second request to amortize the setup over. The 75 milliseconds is what every request after the first one costs. The build-once pattern only earns its keep inside something that serves request after request off the same loaded state.</p>
<p>In FastAPI that “something” is the running server and its event loop. The snapshot and the built scan get created in the lifespan, stored on <code>app.state</code>, and shared by every request that follows:</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-meta">@asynccontextmanager</span></span>
<span class="line"><span class="hl-keyword">async</span> <span class="hl-keyword">def</span> <span class="hl-title function_">lifespan</span>(<span class="hl-params">app: FastAPI</span>):</span>
<span class="line">    table = isiji.deltatable.Merged256()</span>
<span class="line">    <span class="hl-comment"># loads the Delta snapshot once</span></span>
<span class="line">    app.state.delta = DeltaSource(table.dtb)</span>
<span class="line">    <span class="hl-comment"># reuse it, no second log read</span></span>
<span class="line">    <span class="hl-keyword">yield</span></span>
<span class="line"></span>
<span class="line">app = FastAPI(lifespan=lifespan)</span>
<span class="line"></span>
<span class="line"><span class="hl-meta">@app.get(<span class="hl-params"><span class="hl-string">&quot;/rows/{uid}&quot;</span></span>)</span></span>
<span class="line"><span class="hl-keyword">async</span> <span class="hl-keyword">def</span> <span class="hl-title function_">get_rows</span>(<span class="hl-params"></span></span>
<span class="line"><span class="hl-params">    uid: <span class="hl-built_in">str</span>, request: Request, limit: <span class="hl-built_in">int</span> = <span class="hl-number">500</span></span></span>
<span class="line"><span class="hl-params"></span>) -&gt; Response:</span>
<span class="line">    df = <span class="hl-keyword">await</span> request.app.state.delta.fetch(uid, limit)</span>
<span class="line">    <span class="hl-keyword">return</span> Response(</span>
<span class="line">        content=df.write_json(),</span>
<span class="line">        media_type=<span class="hl-string">&quot;application/json&quot;</span>,</span>
<span class="line">    )</span></code></pre>

</figure>
<p><code>DeltaSource</code> is just the build-once idea wrapped in an object. It holds the loaded <code>DeltaTable</code> and the one scan built from it, and every request swaps in its filter:</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">class</span> <span class="hl-title class_">DeltaSource</span>:</span>
<span class="line">    <span class="hl-keyword">def</span> <span class="hl-title function_">__init__</span>(<span class="hl-params">self, table: DeltaTable</span>) -&gt; <span class="hl-literal">None</span>:</span>
<span class="line">        self._table = table</span>
<span class="line">        <span class="hl-comment"># loaded snapshot, kept for the app&#x27;s life</span></span>
<span class="line">        self._base_lf = self._build_lazyframe()</span>
<span class="line">        <span class="hl-comment"># the scan, built once</span></span>
<span class="line">        self._refreshed_at = time.monotonic()</span>
<span class="line">        self._lock = asyncio.Lock()</span>
<span class="line"></span>
<span class="line">    <span class="hl-keyword">def</span> <span class="hl-title function_">_build_lazyframe</span>(<span class="hl-params">self</span>) -&gt; pl.LazyFrame:</span>
<span class="line">        <span class="hl-keyword">return</span> pl.scan_delta(self._table).select(SCALAR_COLUMNS)</span>
<span class="line"></span>
<span class="line">    <span class="hl-keyword">async</span> <span class="hl-keyword">def</span> <span class="hl-title function_">fetch</span>(<span class="hl-params">self, uid: <span class="hl-built_in">str</span>, limit: <span class="hl-built_in">int</span></span>) -&gt; pl.DataFrame:</span>
<span class="line">        <span class="hl-keyword">await</span> self._refresh_if_stale()</span>
<span class="line">        lf = self._base_lf</span>
<span class="line">        <span class="hl-comment"># capture before collecting, survives a swap</span></span>
<span class="line">        <span class="hl-keyword">return</span> <span class="hl-keyword">await</span> asyncio.to_thread(</span>
<span class="line">            <span class="hl-keyword">lambda</span>: lf.<span class="hl-built_in">filter</span>(</span>
<span class="line">                pl.col(<span class="hl-string">&quot;uid&quot;</span>) == uid</span>
<span class="line">            ).head(limit).collect()</span>
<span class="line">        )</span></code></pre>

</figure>
<p>There are a few details to get right. The S3 calls and the <code>.collect()</code> are blocking, so they run in <code>asyncio.to_thread</code> to stay off the event loop. We tested it with 200 concurrent requests and a refresh fired in the middle, and got 200 correct answers and no crashes. End to end, a real request now comes back in about 104 milliseconds: the 75 milliseconds of query plus FastAPI and JSON on top.</p>
<p>There’s one honest catch, and it’s the price of the whole approach. State you build once is frozen in time. It won’t see data written after you built it. So you trade a little freshness for the speed, and you pay it back on a timer. Delta makes the refresh cheap: <code>update_incremental()</code> reads only the new commits instead of replaying the whole log, and then you rebuild the scan so the new file list is actually picked up:</p>

<figure class="code-block">


<pre><code><span class="line">    <span class="hl-keyword">def</span> <span class="hl-title function_">_refresh_blocking</span>(<span class="hl-params">self</span>) -&gt; pl.LazyFrame:</span>
<span class="line">        self._table.update_incremental()</span>
<span class="line">        <span class="hl-comment"># read only new commits, not the whole log</span></span>
<span class="line">        <span class="hl-keyword">return</span> self._build_lazyframe()</span>
<span class="line">        <span class="hl-comment"># rebuild so the new files are seen</span></span>
<span class="line"></span>
<span class="line">    <span class="hl-keyword">async</span> <span class="hl-keyword">def</span> <span class="hl-title function_">_refresh_if_stale</span>(<span class="hl-params">self</span>) -&gt; <span class="hl-literal">None</span>:</span>
<span class="line">        <span class="hl-keyword">if</span> time.monotonic() - self._refreshed_at &lt; TTL_SECONDS:</span>
<span class="line">            <span class="hl-keyword">return</span></span>
<span class="line">        <span class="hl-keyword">async</span> <span class="hl-keyword">with</span> self._lock:</span>
<span class="line">            <span class="hl-comment"># only one request rebuilds</span></span>
<span class="line">            <span class="hl-keyword">if</span> time.monotonic() - self._refreshed_at &lt; TTL_SECONDS:</span>
<span class="line">                <span class="hl-keyword">return</span></span>
<span class="line">            <span class="hl-comment"># double-check after waiting for the lock</span></span>
<span class="line">            new_lf = <span class="hl-keyword">await</span> asyncio.to_thread(</span>
<span class="line">                self._refresh_blocking</span>
<span class="line">            )</span>
<span class="line">            self._base_lf = new_lf</span>
<span class="line">            <span class="hl-comment"># atomic rebind; readers see the</span></span>
<span class="line">            <span class="hl-comment"># old one until here</span></span>
<span class="line">            self._refreshed_at = time.monotonic()</span></code></pre>

</figure>
<p>Two things make that safe under load. Only one request rebuilds when the window expires, because the rest wait on the lock and then find the work already done. And the swap is a single assignment, so an in-flight query either sees the old scan or the new one, never a half-built one. The freshness window is a number you choose on purpose: ours is a day, because the source refreshes on a daily schedule and there’s no point paying for a refresh more often than the data changes.</p>
<hr>
<p>That’s the whole change. Load the snapshot once, build the scan once, project only the columns you need, and per request swap only the filter. The magnitudes are specific to this table and scale with file count, but the pattern holds anywhere you read Delta from a long-lived process: 1700 milliseconds down to 75 milliseconds, with no faster hardware and no change to the data.</p>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">MinIO Disk Partitions and a Wrong Hypothesis</title>
    <link
      href="https://hattajr.github.io/2024/07/02/create-xfs-disk-partition-linux.html"
      rel="alternate" type="text/html"
      title="MinIO Disk Partitions and a Wrong Hypothesis" />
    <published>2024-07-02T00:00:00+00:00</published>
    <updated>2024-07-02T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2024/07/02/create-xfs-disk-partition-linux</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[I once split the single HDD in my MinIO server into four XFS partitions so MinIO would see four drives, and it made MinIO slower. The setup itself is still correct if you have more than one physical disk, so I'm keeping the steps here, along with why this backfired so badly on a single disk.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2024/07/02/create-xfs-disk-partition-linux.html">
      <![CDATA[
<header>
  <h1>MinIO Disk Partitions and a Wrong Hypothesis</h1>
  <time class="meta" datetime="2024-07-02">Jul 2, 2024</time>
</header>
<p>I once split the single HDD in my MinIO server into four XFS partitions so MinIO would see four drives, and it made MinIO slower. The setup itself is still correct if you have more than one physical disk, so I’m keeping the steps here, along with why this backfired so badly on a single disk.</p>
<p>The idea seemed reasonable at the time. MinIO wants XFS and one mount point per drive, and it spreads work across every drive it has, so I figured that if I gave MinIO four “drives” instead of one, it would read and write four times in parallel and I’d get four times the throughput for free.</p>
<p><strong><strong>Warning: everything below wipes the target disk, so double-check the device name before you write anything.</strong></strong></p>
<section id="Partitioning-and-Formatting">

<h2><a href="#Partitioning-and-Formatting">Partitioning and Formatting</a></h2>
<p>First, find the disk. I prefer <code>lsblk</code> over <code>fdisk -l</code> because this shows the tree of disks, partitions, mount points, etc. at a glance. You’re looking for the drive with no mount points, such as <code>/dev/sdb</code>, which I’ll call <code>/dev/sdX</code> below.</p>

<figure class="code-block">


<pre><code><span class="line">lsblk</span>
<span class="line">sudo fdisk -l /dev/sdX</span></code></pre>

</figure>
<p>Then create the partitions with <code>fdisk</code>.</p>

<figure class="code-block">


<pre><code><span class="line">sudo fdisk /dev/sdX</span></code></pre>

</figure>
<p>Inside <code>fdisk</code>, press <code>g</code> first to make a GPT partition table. The older MBR table only allows 4 primary partitions and can’t address more than 2 TiB, and any HDD you’d buy for object storage today is bigger than that. Then for each partition press <code>n</code>, Enter for the default number, Enter for the default first sector, and type a size such as <code>+500G</code> for the last sector. Press <code>w</code> at the end to write the table and exit.</p>
<p>Next, format every partition as XFS and give each one a label. The label is what we’ll mount by later, and XFS caps labels at 12 characters, so keep them short.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">for</span> i <span class="hl-keyword">in</span> {1..4}; <span class="hl-keyword">do</span></span>
<span class="line">  sudo mkfs.xfs -f -L MINIODISK<span class="hl-variable">$i</span> /dev/sdX<span class="hl-variable">$i</span></span>
<span class="line"><span class="hl-keyword">done</span></span></code></pre>

</figure>
<p>Note the range is <code>{1..4}</code> with <strong>two</strong> dots. I had three in my own notes at first, and bash doesn’t expand that at all, it just passes along the literal path <code>/dev/sdX{1...4}</code>.</p>
</section>
<section id="Mounting-and-Keeping-It-Mounted">

<h2><a href="#Mounting-and-Keeping-It-Mounted">Mounting (and Keeping It Mounted)</a></h2>
<p>Create the mount points and mount everything.</p>

<figure class="code-block">


<pre><code><span class="line">sudo <span class="hl-built_in">mkdir</span> -p /mnt/minio/disk{1..4}</span>
<span class="line"><span class="hl-keyword">for</span> i <span class="hl-keyword">in</span> {1..4}; <span class="hl-keyword">do</span></span>
<span class="line">  sudo mount LABEL=MINIODISK<span class="hl-variable">$i</span> /mnt/minio/disk<span class="hl-variable">$i</span></span>
<span class="line"><span class="hl-keyword">done</span></span></code></pre>

</figure>
<p>To survive a reboot, the mounts go in <code>/etc/fstab</code>. Don’t use <code>/dev/sdX1</code> there, because device names can shuffle between boots if you add or remove a disk, and then the wrong disk shows up at the wrong path. MinIO’s docs recommend mounting by label over UUID, because if a disk dies, you format the replacement with the same label and <code>fstab</code> doesn’t change.<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a></p>

<figure class="code-block">


<pre><code><span class="line">LABEL=MINIODISK1 /mnt/minio/disk1 xfs defaults,noatime 0 0</span>
<span class="line">LABEL=MINIODISK2 /mnt/minio/disk2 xfs defaults,noatime 0 0</span>
<span class="line">LABEL=MINIODISK3 /mnt/minio/disk3 xfs defaults,noatime 0 0</span>
<span class="line">LABEL=MINIODISK4 /mnt/minio/disk4 xfs defaults,noatime 0 0</span></code></pre>

</figure>
<p><code>noatime</code> stops the kernel from writing an access timestamp on every read, which MinIO has no use for.</p>
<p>Finally, test it <strong>before</strong> you reboot. Stop MinIO first, otherwise <code>umount</code> fails because the mounts are busy and <code>mount -a</code> has nothing to test. If <code>mount -a</code> complains, fix it now, because a broken <code>fstab</code> entry can drop the machine into emergency mode on the next boot and that’s a much worse time to find out.</p>

<figure class="code-block">


<pre><code><span class="line">sudo umount /mnt/minio/disk{1..4}</span>
<span class="line">sudo mount -a</span>
<span class="line"><span class="hl-built_in">df</span> -h | grep minio</span></code></pre>

</figure>
</section>
<section id="One-Disk-One-Actuator">

<h2><a href="#One-Disk-One-Actuator">One Disk, One Actuator</a></h2>
<p>So all of this works, and then MinIO got slower. I did benchmark it, but I didn’t keep the numbers around. The one I do remember is the job reading from MinIO: about 10 minutes with the disk as a single partition, and about 17 with four.</p>
<p>The reason is mechanical. An HDD has a read/write head for each platter surface, but every head is mounted on a single actuator (a stack of arms that pivots together), so the heads can’t go to different places at the same time. To read anything, the whole actuator swings to the right track and then waits for the platter to spin the right sector underneath it, and on a 7200 RPM drive that costs several milliseconds every time. Some enterprise drives (Seagate’s MACH.2, for example) have two independent actuators, and those show up to the OS as two separate devices.<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a> My drive wasn’t.</p>
<p>MinIO, of course, has no idea. It sees four mounts and treats them as four independent drives. Its erasure coding splits every object into data and parity shards and puts one shard on each drive in the set, so a single write became four writes to four different regions of the same platters. A big file the disk could have streamed in a single sequential pass turned into the actuator bouncing between partitions, and seeking is the thing an HDD is worst at.</p>
<p>It also didn’t buy me any safety. Erasure coding exists so you can lose a drive, but four partitions on the same disk die together.</p>
<p>What I’d do now is simple. With a single HDD, give MinIO the whole disk as a single XFS mount and accept that it has no redundancy beyond the disk itself (MinIO supports that single-drive setup fine). If you want speed or redundancy, buy more physical disks and use the steps above with one partition per disk. Labels have to be unique across every attached disk, so name them per disk (<code>MINIODISK1</code> on the first disk, <code>MINIODISK2</code> on the second, etc.). And if you’re on SSD or NVMe, there are no moving heads, parallel requests are cheap, and MinIO recommends flash anyway, but partitioning a single SSD still gets you zero redundancy.</p>
</section>
<section role="doc-endnotes">
<hr>
<ol>
<li id="fn1">
<p>MinIO’s own deployment docs use exactly this <code>LABEL=... defaults,noatime</code> style in their <code>fstab</code> example.<a href="#fnref1" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn2">
<p>Tom’s Hardware has a <a href="https://www.tomshardware.com/news/seagate-lists-dual-actuator-hdd-exos-2x14">good write-up</a> on MACH.2 if you’re curious how that works.<a href="#fnref2" role="doc-backlink">↩︎</a></p>
</li>
</ol>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Querying Deltalake From Apache Superset Dashboard</title>
    <link href="https://hattajr.github.io/2023/09/20/superset-deltalake.html"
      rel="alternate" type="text/html"
      title="Querying Deltalake From Apache Superset Dashboard" />
    <published>2023-09-20T00:00:00+00:00</published>
    <updated>2023-09-20T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2023/09/20/superset-deltalake</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[We run our own Delta lakehouse on MinIO, and we'd just finished migrating terabytes of data into it. Every night a pipeline runs the aggregations and writes the results back into the lakehouse as more Delta tables. Which is great, until someone asks to see those numbers on a dashboard.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2023/09/20/superset-deltalake.html">
      <![CDATA[
<header>
  <h1>Querying Deltalake From Apache Superset Dashboard</h1>
  <time class="meta" datetime="2023-09-20">Sep 20, 2023</time>
</header>
<p>We run our own Delta lakehouse on MinIO, and we’d just finished migrating terabytes of data into it. Every night a pipeline runs the aggregations and writes the results back into the lakehouse as more Delta tables. Which is great, until someone asks to <strong>see</strong> those numbers on a dashboard.</p>
<p>That’s where I got stuck. Grafana was the obvious first stop, but it has no way to read a Delta table, so I went looking and found Apache Superset. Superset can’t read Delta directly either, but it’s Python all the way down and lets you hook your own code into a query, so I forked it and added a small trick to make it work.</p>
<p>DuckDB <strong>can</strong> read plain Parquet over HTTP, though, and a Delta table is mostly just Parquet files plus a transaction log saying which of those files are currently part of the table. So the plan was to let the <code>deltalake</code> Python package read the log, turn the active files into presigned MinIO URLs, and hand that list to DuckDB inside a Superset SQL query.</p>
<p>The glue is Superset’s Jinja templating. Superset lets you register Python functions in <code>JINJA_CONTEXT_ADDONS</code>, and then you can call them from SQL Lab with <code>{{ ... }}</code>. Whatever the function returns gets rendered into the query text before it’s sent to the database.</p>
<section id="Setup">

<h2><a href="#Setup">Setup</a></h2>
<p>This assumes the Docker Compose setup from the Superset repo. Superset needs a few extra packages: <code>duckdb-engine</code> so it can talk to DuckDB at all, <code>deltalake</code> to read the Delta log, <code>minio</code> to presign the URLs, and <code>python-dotenv</code> for the credentials. In the Docker setup, extra packages go in <code>docker/requirements-local.txt</code>, and then you rebuild the stack.</p>

<figure class="code-block">


<pre><code><span class="line">duckdb-engine</span>
<span class="line">deltalake</span>
<span class="line">minio</span>
<span class="line">python-dotenv</span></code></pre>

</figure>
<p>Next, put the MinIO credentials in a <code>.env</code> file next to the config (<code>docker/pythonpath_dev/.env</code>).</p>

<figure class="code-block">


<pre><code><span class="line">AWS_ACCESS_KEY_ID=...</span>
<span class="line">AWS_SECRET_ACCESS_KEY=...</span>
<span class="line">AWS_ENDPOINT_URL=http://minio:9000</span></code></pre>

</figure>
<p>Then the config itself. I originally edited <code>superset_config.py</code> directly, but the Superset Docker docs say to put local overrides in <code>docker/pythonpath_dev/superset_config_docker.py</code> (it’s git-ignored and gets loaded on top), so that’s where this goes.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">from</span> datetime <span class="hl-keyword">import</span> timedelta</span>
<span class="line"><span class="hl-keyword">from</span> pathlib <span class="hl-keyword">import</span> Path</span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">from</span> deltalake <span class="hl-keyword">import</span> DeltaTable</span>
<span class="line"><span class="hl-keyword">from</span> dotenv <span class="hl-keyword">import</span> dotenv_values</span>
<span class="line"><span class="hl-keyword">from</span> minio <span class="hl-keyword">import</span> Minio</span>
<span class="line"></span>
<span class="line">REQUIRED_KEYS = (</span>
<span class="line">    <span class="hl-string">&quot;AWS_ACCESS_KEY_ID&quot;</span>,</span>
<span class="line">    <span class="hl-string">&quot;AWS_SECRET_ACCESS_KEY&quot;</span>,</span>
<span class="line">    <span class="hl-string">&quot;AWS_ENDPOINT_URL&quot;</span>,</span>
<span class="line">)</span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">def</span> <span class="hl-title function_">load_minio_env</span>() -&gt; <span class="hl-built_in">dict</span>[<span class="hl-built_in">str</span>, <span class="hl-built_in">str</span>]:</span>
<span class="line">    <span class="hl-string">&quot;&quot;&quot;Read MinIO credentials from the .env next to this file.&quot;&quot;&quot;</span></span>
<span class="line">    env = dotenv_values(Path(__file__).with_name(<span class="hl-string">&quot;.env&quot;</span>))</span>
<span class="line">    missing = [key <span class="hl-keyword">for</span> key <span class="hl-keyword">in</span> REQUIRED_KEYS <span class="hl-keyword">if</span> <span class="hl-keyword">not</span> env.get(key)]</span>
<span class="line">    <span class="hl-keyword">if</span> missing:</span>
<span class="line">        <span class="hl-keyword">raise</span> ValueError(<span class="hl-string">f&quot;missing in .env: <span class="hl-subst">{missing}</span>&quot;</span>)</span>
<span class="line">    <span class="hl-keyword">return</span> {k: v <span class="hl-keyword">for</span> k, v <span class="hl-keyword">in</span> env.items() <span class="hl-keyword">if</span> v <span class="hl-keyword">is</span> <span class="hl-keyword">not</span> <span class="hl-literal">None</span>}</span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">def</span> <span class="hl-title function_">delta_table</span>(<span class="hl-params">bucket: <span class="hl-built_in">str</span>, table_path: <span class="hl-built_in">str</span></span>) -&gt; <span class="hl-built_in">list</span>[<span class="hl-built_in">str</span>]:</span>
<span class="line">    <span class="hl-string">&quot;&quot;&quot;Return presigned URLs for the active files of a Delta table.&quot;&quot;&quot;</span></span>
<span class="line">    env = load_minio_env()</span>
<span class="line">    table = DeltaTable(</span>
<span class="line">        <span class="hl-string">f&quot;s3://<span class="hl-subst">{bucket}</span>/<span class="hl-subst">{table_path}</span>&quot;</span>,</span>
<span class="line">        storage_options={</span>
<span class="line">            <span class="hl-string">&quot;AWS_ACCESS_KEY_ID&quot;</span>: env[<span class="hl-string">&quot;AWS_ACCESS_KEY_ID&quot;</span>],</span>
<span class="line">            <span class="hl-string">&quot;AWS_SECRET_ACCESS_KEY&quot;</span>: env[<span class="hl-string">&quot;AWS_SECRET_ACCESS_KEY&quot;</span>],</span>
<span class="line">            <span class="hl-string">&quot;AWS_ENDPOINT_URL&quot;</span>: env[<span class="hl-string">&quot;AWS_ENDPOINT_URL&quot;</span>],</span>
<span class="line">            <span class="hl-string">&quot;AWS_REGION&quot;</span>: env.get(<span class="hl-string">&quot;AWS_REGION&quot;</span>, <span class="hl-string">&quot;us-east-1&quot;</span>),</span>
<span class="line">            <span class="hl-string">&quot;AWS_ALLOW_HTTP&quot;</span>: <span class="hl-string">&quot;true&quot;</span>,</span>
<span class="line">        },</span>
<span class="line">    )</span>
<span class="line">    <span class="hl-comment"># The MinIO client wants host:port, not a URL.</span></span>
<span class="line">    endpoint = env[<span class="hl-string">&quot;AWS_ENDPOINT_URL&quot;</span>].split(<span class="hl-string">&quot;//&quot;</span>, <span class="hl-number">1</span>)[<span class="hl-number">1</span>]</span>
<span class="line">    client = Minio(</span>
<span class="line">        endpoint,</span>
<span class="line">        access_key=env[<span class="hl-string">&quot;AWS_ACCESS_KEY_ID&quot;</span>],</span>
<span class="line">        secret_key=env[<span class="hl-string">&quot;AWS_SECRET_ACCESS_KEY&quot;</span>],</span>
<span class="line">        secure=<span class="hl-literal">False</span>,</span>
<span class="line">    )</span>
<span class="line">    prefix = <span class="hl-string">f&quot;s3://<span class="hl-subst">{bucket}</span>/&quot;</span></span>
<span class="line">    <span class="hl-keyword">return</span> [</span>
<span class="line">        client.presigned_get_object(</span>
<span class="line">            bucket,</span>
<span class="line">            uri.removeprefix(prefix),</span>
<span class="line">            expires=timedelta(days=<span class="hl-number">1</span>),</span>
<span class="line">        )</span>
<span class="line">        <span class="hl-keyword">for</span> uri <span class="hl-keyword">in</span> table.file_uris()</span>
<span class="line">    ]</span>
<span class="line"></span>
<span class="line"></span>
<span class="line">FEATURE_FLAGS = {<span class="hl-string">&quot;ENABLE_TEMPLATE_PROCESSING&quot;</span>: <span class="hl-literal">True</span>}</span>
<span class="line">JINJA_CONTEXT_ADDONS = {<span class="hl-string">&quot;delta_table&quot;</span>: delta_table}</span></code></pre>

</figure>
<p>Two things here are easy to miss. First, Jinja does nothing until the <code>ENABLE_TEMPLATE_PROCESSING</code> feature flag is on. Without it the <code>{{ ... }}</code> just goes to the database as literal text. (If you already set other <code>FEATURE_FLAGS</code>, add it to that dict rather than replacing it.) Second, the <code>storage_options</code> keys. The delta-rs docs use <code>AWS_ALLOW_HTTP</code> for a MinIO that serves plain HTTP, which is what you get from a default local MinIO on port 9000. That’s also why the MinIO client has <code>secure=False</code>. If your MinIO sits behind HTTPS, flip it to <code>True</code>.</p>
<p>Finally, add a database in Superset with the SQLAlchemy URI <code>duckdb:///:memory:</code>. DuckDB doesn’t need to store anything here, it only reads the Parquet files on each query.</p>
</section>
<section id="Querying-It">

<h2><a href="#Querying-It">Querying It</a></h2>
<p>Then in SQL Lab, against the DuckDB database, you call the function like any other Jinja macro.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">SELECT</span> test_id, test_actual_duration</span>
<span class="line"><span class="hl-keyword">FROM</span> read_parquet({{ delta_table(<span class="hl-string">&#x27;spatch&#x27;</span>, <span class="hl-string">&#x27;path/to/table&#x27;</span>) }})</span>
<span class="line">LIMIT <span class="hl-number">7</span>;</span></code></pre>

</figure>
<p>The reason this works at all is kind of lucky. <code>delta_table</code> returns a Python list of strings, and Jinja renders the list with Python’s own formatting, <code>['http://...', 'http://...']</code>. That happens to be valid DuckDB list syntax, and <code>read_parquet</code> accepts a list of files.</p>
</section>
<section id="Where-the-Trick-Falls-Apart">

<h2><a href="#Where-the-Trick-Falls-Apart">Where the Trick Falls Apart</a></h2>
<p>This is a hack, and DuckDB only ever sees Parquet files. So you lose anything that lives only in the Delta log, such as these three I know about.</p>
<ul>
<li>
Deletion vectors. Rows that were soft-deleted are still in the Parquet files, so they come back.
</li>
<li>
Partition columns. Delta stores partition values in the log and usually not in the data files, so those columns are missing from <code>read_parquet</code> unless you recover them from the file paths.
</li>
<li>
Column mapping. If the table renamed columns using column mapping, you get the physical column names.
</li>
</ul>
<p>Also be careful who can use this. <code>delta_table</code> takes the bucket and path straight from the SQL, so anyone with SQL Lab access can presign any file the MinIO credentials can read, and those URLs end up in the query history. Give Superset a MinIO user that can only read the tables you actually want on dashboards.</p>
<p>The presigned URLs also expire after one day, which is fine for SQL Lab since the function runs on every query, but every query also re-reads the Delta log and presigns every active file. The whole lakehouse is multiple terabytes spread over millions of files, so pointing this at a raw table would be hopeless, since every one of those URLs ends up pasted into the SQL text. The dashboards only read the nightly aggregate tables, though, and those charts took 5 to 10 seconds to load. Not fast, but fine for numbers that change once a day.</p>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Notes From a Databricks Delta Lake Training</title>
    <link href="https://hattajr.github.io/2023/04/18/databricks-webinar.html"
      rel="alternate" type="text/html"
      title="Notes From a Databricks Delta Lake Training" />
    <published>2023-04-18T00:00:00+00:00</published>
    <updated>2023-04-18T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2023/04/18/databricks-webinar</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[These are my notes from a three-day Databricks training on Delta Lake and Spark, cleaned up a bit so they make sense to someone other than me. The organizers said up front that the recording and slides would not be shared, so if you want the full course, search Databricks Academy for the self-paced Data Engineer Learning Plan. The notebooks are also public in the databricks-academy repo on GitHub.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2023/04/18/databricks-webinar.html">
      <![CDATA[
<header>
  <h1>Notes From a Databricks Delta Lake Training</h1>
  <time class="meta" datetime="2023-04-18">Apr 18, 2023</time>
</header>
<p>These are my notes from a three-day Databricks training on Delta Lake and Spark, cleaned up a bit so they make sense to someone other than me. The organizers said up front that the recording and slides would <strong>not</strong> be shared, so if you want the full course, search Databricks Academy for the self-paced “Data Engineer Learning Plan”. The notebooks are also public in the <a href="https://github.com/databricks-academy/data-engineering-with-databricks-english">databricks-academy</a> repo on GitHub.</p>
<p>We run Delta Lake ourselves on MinIO rather than on Databricks, so I’m mostly interested in the parts that are plain Spark and Delta. But I kept the Databricks-only bits too, since this is my record of the course.</p>
<section id="Day-1">

<h2><a href="#Day-1">Day 1</a></h2>
<ul>
<li>
Embrace <code>spark.sql(...)</code>. A lot of the course is written in SQL, and anything you can do with the DataFrame API you can usually also do in SQL against the same tables. Both end up as the same query plan, so this is mostly about which one reads better.
</li>
</ul>
</section>
<section id="Day-2:-Tables">

<h2><a href="#Day-2:-Tables">Day 2: Tables</a></h2>
<ul>
<li>
<p>The Hive metastore is where table definitions live, meaning the name, the schema, the location of the files, etc. Databricks is pushing Unity Catalog for this now, but the idea is the same.</p>
</li>
<li>
<p>Delta tables are either <strong>managed</strong> or <strong>external</strong>. Drop a managed table and both the metadata and the Parquet files are deleted. Drop an external table, which is one created with a <code>LOCATION</code>, and only the metadata goes away. The files stay where they are.</p>
</li>
<li>
<p>To see what a table actually is, use <code>DESCRIBE</code>.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">DESCRIBE</span> DETAIL <span class="hl-string">&#x27;/data/events/&#x27;</span>;</span>
<span class="line"><span class="hl-keyword">DESCRIBE</span> DETAIL events_table;</span>
<span class="line"><span class="hl-keyword">DESCRIBE</span> EXTENDED events_table;</span></code></pre>

</figure>
<p><code>DESCRIBE DETAIL</code> gives you the Delta-specific stuff such as format, location, number of files and size, and <code>DESCRIBE EXTENDED</code> adds the storage location, provider and table properties under the schema.</p>
</li>
<li>
<p><code>CREATE SCHEMA IF NOT EXISTS ${da.schema_name}</code> creates a schema, which is a database, not a table. I wrote down “what’s the difference with <code>DeltaTable.createIfNotExists</code>?” during the session, and the answer is that they aren’t the same thing at all. <code>DeltaTable.createIfNotExists</code> is the Python builder for a <strong>table</strong>, so its SQL twin is <code>CREATE TABLE IF NOT EXISTS</code>.</p>
</li>
<li>
<p>Define the schema when you create the table. Letting Spark guess it works for a demo, but this is where type surprises come from later.</p>
</li>
<li>
<p>CTAS (<code>CREATE TABLE ... AS SELECT</code>) infers the schema from the query and doesn’t let you pass file options. For something like CSV with a header and a custom delimiter, the course first creates a temp view with <code>USING CSV OPTIONS (...)</code>. Then it does the CTAS from that view. See the <a href="https://github.com/databricks-academy/data-engineering-with-databricks-english/blob/published/04%20-%20ETL%20with%20Spark%20SQL/DE%204.3%20-%20Creating%20Delta%20Tables.sql">course notebook</a>.</p>
</li>
<li>
<p>You can enrich a table at creation time with a <code>COMMENT</code>, a <code>LOCATION</code>, and extra columns. <code>current_timestamp()</code> records when the row was loaded and <code>input_file_name()</code> records which source file it came from. Both are also in <code>pyspark.sql.functions</code>.</p>

<figure>

<img alt="Enriching a Delta table with options and metadata" src="/assets/2023-04-18-databricks-webinar/db-webinar-1.png">
</figure>

<figure>

<img alt="CTAS with COMMENT, LOCATION, PARTITIONED BY and metadata columns" src="/assets/2023-04-18-databricks-webinar/db-webinar-2.png">
</figure>
</li>
<li>
<p><strong><strong>As a best practice, don’t partition small Delta tables.</strong></strong> The course’s own wording is that most small-to-medium Delta tables won’t benefit from partitioning, because it splits the data into lots of small files and gets in the way of compaction and data skipping. Default to non-partitioned.</p>
</li>
<li>
<p><code>SHALLOW CLONE</code> copies only the Delta log and still points at the source table’s files, so it’s cheap and good for testing against real data. <code>DEEP CLONE</code> copies the files too.</p>
</li>
<li>
<p>Avoid the RDD API. The DataFrame and SQL APIs go through Spark’s query optimizer and RDDs don’t.</p>
</li>
<li>
<p><code>df.schema</code> gives you the schema as an object, and you can reuse it to create another table with the same shape.</p>
</li>
<li>
<p><code>df.collect()</code> pulls <strong>all</strong> the data from every executor onto the driver. Fine for 10 rows, a crash for 10 billion.</p>
</li>
<li>
<p><code>df.createOrReplaceTempView("name")</code> lets you query a DataFrame with SQL. It’s easy to think this creates a temporary table in memory, but it doesn’t. It’s just a name for the DataFrame’s query plan, scoped to the Spark session. Nothing is computed or cached until you query it.</p>
</li>
<li>
<p>DataFrames are immutable, so every transformation returns a new DataFrame and leaves the old one alone.</p>
</li>
<li>
<p><code>count(*)</code> counts every row, and <code>count(col)</code> skips rows where <code>col</code> is null.</p>
</li>
<li>
<p>Use <code>.</code> to reach into nested struct fields, as in <code>SELECT user.address.city</code>.</p>
</li>
<li>
<p>Here’s the course’s example of the timestamp and string helpers, in SQL and PySpark. The <code>/ 1e6</code> is there because <code>user_first_touch_timestamp</code> is in microseconds.</p>

<figure>

<img alt="dateformat and regexpextract in SQL and PySpark" src="/assets/2023-04-18-databricks-webinar/db-webinar-3.png">
</figure>
</li>
<li>
<p><code>explode</code> turns one row per array into one row per element, so it can blow a table up fast. Select only the columns you need <strong>before</strong> exploding.</p>
</li>
</ul>
</section>
<section id="Day-3:-Streaming">

<h2><a href="#Day-3:-Streaming">Day 3: Streaming</a></h2>
<ul>
<li>
<p>Auto Loader, the <code>cloudFiles</code> source, is the Databricks way to ingest new files incrementally from a directory. It only picks up files it hasn’t seen before.</p>
</li>
<li>
<p>For schema evolution, Auto Loader stores the schema it inferred in a schema location and tracks changes over time. But data that doesn’t match the schema doesn’t get thrown away. It goes into a <code>_rescued_data</code> column, so you can look at it later.</p>
</li>
<li>
<p><code>cloudFiles.schemaHints</code> lets you pin the type of a few columns and let Auto Loader infer the rest.</p>
</li>
<li>
<p>Checkpoints are what make structured streaming restartable. Lose the checkpoint and the stream doesn’t know what it already processed.</p>
</li>
<li>
<p>A streaming pipeline should be fault tolerant, replayable, durable and idempotent.</p>
</li>
<li>
<p>You can treat a stream like a table in SQL by registering a temp view on it, and write the result back out with <code>writeStream</code>. This is DE 6.3L in the course.</p>

<figure class="code-block">


<pre><code><span class="line">(spark.readStream</span>
<span class="line">    .table(<span class="hl-string">&quot;bronze&quot;</span>)</span>
<span class="line">    .createOrReplaceTempView(<span class="hl-string">&quot;streaming_tmp_vw&quot;</span>))</span></code></pre>

</figure>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">SELECT</span> device_id, <span class="hl-built_in">count</span>(device_id) <span class="hl-keyword">AS</span> total_recordings</span>
<span class="line"><span class="hl-keyword">FROM</span> streaming_tmp_vw</span>
<span class="line"><span class="hl-keyword">GROUP</span> <span class="hl-keyword">BY</span> device_id</span></code></pre>

</figure>
<p>And this stops everything that’s still running in the notebook.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">for</span> s <span class="hl-keyword">in</span> spark.streams.active:</span>
<span class="line">    s.stop()</span>
<span class="line">    s.awaitTermination()</span></code></pre>

</figure>
</li>
<li>
<p>Triggers control when the next micro-batch runs. <code>.trigger(once=True)</code> processes everything available in one batch and stops, and because it keeps a checkpoint, the next run picks up where the last one left off. The course recommends <code>.trigger(availableNow=True)</code> instead, which does the same thing but in multiple batches, so a big backlog doesn’t land in one giant batch.</p>

<figure>

<img alt="Trigger types for structured streaming" src="/assets/2023-04-18-databricks-webinar/db-webinar-4.png">
</figure>
</li>
</ul>
</section>
<section id="Multi-Hop-Architecture">

<h2><a href="#Multi-Hop-Architecture">Multi-Hop Architecture</a></h2>
<p>This is the bronze/silver/gold layout, which Databricks also calls the medallion architecture. Bronze is the raw data as it arrived, silver is cleaned, deduplicated and schema-enforced but still at the original grain, and gold is aggregated for dashboards, reports and ML.</p>
<p><img alt="Multi-hop overview" src="/assets/2023-04-18-databricks-webinar/db-webinar-5.png">
<img alt="Silver layer" src="/assets/2023-04-18-databricks-webinar/db-webinar-6.png">
<img alt="Gold layer" src="/assets/2023-04-18-databricks-webinar/db-webinar-7.png"></p>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Debugging Spark Streaming in a Notebook</title>
    <link
      href="https://hattajr.github.io/2022/04/30/how-to-debug-spark-streaming.html"
      rel="alternate" type="text/html"
      title="Debugging Spark Streaming in a Notebook" />
    <published>2022-04-30T00:00:00+00:00</published>
    <updated>2022-04-30T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2022/04/30/how-to-debug-spark-streaming</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[For a while I was the entire data and AI team at a small startup. There was no senior engineer to ask, and I don't have a CS degree either (I graduated in mechanical engineering, of all things). Nobody around me really talked about tests. So my idea of testing a Spark job was running the whole pipeline end to end and eyeballing the output.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2022/04/30/how-to-debug-spark-streaming.html">
      <![CDATA[
<header>
  <h1>Debugging Spark Streaming in a Notebook</h1>
  <time class="meta" datetime="2022-04-30">Apr 30, 2022</time>
</header>
<p>For a while I was the entire data and AI team at a small startup. There was no senior engineer to ask, and I don’t have a CS degree either (I graduated in mechanical engineering, of all things). Nobody around me really talked about tests. So my idea of “testing” a Spark job was running the whole pipeline end to end and eyeballing the output.</p>
<p>Then I got stuck on a Spark API that wasn’t covered in the docs or on Stack Overflow. I don’t even remember which API it was anymore. But I had the idea to go look at how Spark’s <strong>own</strong> test suite uses this API, since the people who wrote the API must have tested it somewhere.</p>
<p>That’s where I found out you can actually drive a Spark job step by step from a notebook, feed it a small amount of data, and stop the query when the data is done. That felt like finding a gem. Ever since, the first thing I read to learn how a framework is really meant to be used is its test cases.</p>
<section id="Streams-Never-End">

<h2><a href="#Streams-Never-End">Streams Never End</a></h2>
<p>A streaming query doesn’t end. You call <code>.start()</code> and the query keeps running, waiting for new data forever. That’s the point in production, but in a notebook this means you either kill the cell by hand or you never really know if the stream finished processing the data you gave it.</p>
<p>I wanted to run a stream on a small slice of data, look at every micro-batch, and have the query stop on its own once there’s nothing left to process.</p>
</section>
<section id="The-Notebook-Version">

<h2><a href="#The-Notebook-Version">The Notebook Version</a></h2>
<p>This is what I ended up with. It reads the change data feed of a Delta table, limits each micro-batch to a tiny size so I can see more than one batch, and polls the query status until the stream is idle.</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">import</span> logging</span>
<span class="line"><span class="hl-keyword">import</span> time</span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">from</span> pyspark.sql <span class="hl-keyword">import</span> DataFrame</span>
<span class="line"></span>
<span class="line">logging.basicConfig(level=logging.INFO)</span>
<span class="line">logger = logging.getLogger(__name__)</span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">def</span> <span class="hl-title function_">batch_function</span>(<span class="hl-params">cdf_df: DataFrame, batch_id: <span class="hl-built_in">int</span></span>) -&gt; <span class="hl-literal">None</span>:</span>
<span class="line">    logger.info(<span class="hl-string">&quot;batch %s: %s rows&quot;</span>, batch_id, cdf_df.count())</span>
<span class="line"></span>
<span class="line"></span>
<span class="line">query = (</span>
<span class="line">    spark.readStream</span>
<span class="line">    .<span class="hl-built_in">format</span>(<span class="hl-string">&quot;delta&quot;</span>)</span>
<span class="line">    .options(readChangeFeed=<span class="hl-string">&quot;true&quot;</span>, maxBytesPerTrigger=<span class="hl-string">&quot;1K&quot;</span>)</span>
<span class="line">    .table(<span class="hl-string">&quot;_temp_max_bytes_calc&quot;</span>)</span>
<span class="line">    .select(<span class="hl-string">&quot;id&quot;</span>, <span class="hl-string">&quot;year&quot;</span>, <span class="hl-string">&quot;month&quot;</span>)</span>
<span class="line">    .drop_duplicates()</span>
<span class="line">    .writeStream</span>
<span class="line">    .foreachBatch(batch_function)</span>
<span class="line">    .trigger(processingTime=<span class="hl-string">&quot;5 seconds&quot;</span>)</span>
<span class="line">    .queryName(<span class="hl-string">&quot;my query&quot;</span>)</span>
<span class="line">    .start()</span>
<span class="line">)</span>
<span class="line"></span>
<span class="line">time.sleep(<span class="hl-number">5</span>)</span>
<span class="line"><span class="hl-keyword">while</span> query.isActive:</span>
<span class="line">    stop_conditions = [</span>
<span class="line">        <span class="hl-keyword">not</span> query.status[<span class="hl-string">&quot;isDataAvailable&quot;</span>],</span>
<span class="line">        <span class="hl-keyword">not</span> query.status[<span class="hl-string">&quot;isTriggerActive&quot;</span>],</span>
<span class="line">        query.status[<span class="hl-string">&quot;message&quot;</span>] != <span class="hl-string">&quot;Initializing sources&quot;</span>,</span>
<span class="line">    ]</span>
<span class="line"></span>
<span class="line">    <span class="hl-keyword">if</span> <span class="hl-built_in">all</span>(stop_conditions):</span>
<span class="line">        query.stop()</span>
<span class="line"></span>
<span class="line">    time.sleep(<span class="hl-number">1</span>)</span>
<span class="line"></span>
<span class="line">query.awaitTermination(<span class="hl-number">10</span>)</span></code></pre>

</figure>
<p>A few of these lines need explaining.</p>
<ul>
<li>
<code>foreachBatch</code> hands you each micro-batch as a regular DataFrame, along with its batch id. Inside the function you can do anything you’d do with a normal DataFrame, such as count it, <code>display</code> it, write it somewhere, etc. That makes <code>foreachBatch</code> the easiest place to see the stream’s work.
</li>
<li>
<code>maxBytesPerTrigger</code> is a <strong>soft</strong> limit on how much data goes into one micro-batch. Setting the limit absurdly low (<code>1K</code>) forces the stream to split even a small table into several batches, so you can check that your logic also works across batches, since a single big batch can hide bugs. The source table needs change data feed enabled for <code>readChangeFeed</code> to work.
</li>
<li>
<code>query.status</code> returns a dict with <code>message</code>, <code>isDataAvailable</code> and <code>isTriggerActive</code>. When there’s no data available, no trigger running, and the query is past its startup phase, the stream has caught up and is safe to stop. The <code>"Initializing sources"</code> check is there because right after <code>start()</code> the other two flags are also false, and without that check the loop would stop the query before Spark processed anything. The <code>time.sleep(5)</code> at the top is there for the same reason, and it’s doing more work than it looks. Before the sources even start initializing, the status message is <code>"Initializing StreamExecution"</code> with both flags false too, so without the sleep the loop could still stop the query too early.
</li>
</ul>
</section>
<section id="The-Simpler-Way">

<h2><a href="#The-Simpler-Way">The Simpler Way</a></h2>
<p>The polling loop works, but Spark also has a method for exactly this, <code>query.processAllAvailable()</code>. Its docstring says it “blocks until all available data in the source has been processed and committed to the sink” and that it’s “intended for testing”. So the whole loop can be two lines.</p>

<figure class="code-block">


<pre><code><span class="line">query.processAllAvailable()</span>
<span class="line">query.stop()</span></code></pre>

</figure>
<p>The catch from the docs is that if data keeps arriving, this can block forever, so use this on a fixed test table, not a live source.</p>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Software Development Quality</title>
    <link
      href="https://hattajr.github.io/2022/04/09/software-development-quality.html"
      rel="alternate" type="text/html" title="Software Development Quality" />
    <published>2022-04-09T00:00:00+00:00</published>
    <updated>2022-04-09T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2022/04/09/software-development-quality</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[I came across this mind map in a post on Twitter (I've tried to find the original again to credit the author, but no luck), and it changed how I think about what to work on first when I build software.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2022/04/09/software-development-quality.html">
      <![CDATA[
<header>
  <h1>Software Development Quality</h1>
  <time class="meta" datetime="2022-04-09">Apr 9, 2022</time>
</header>
<p>I came across this mind map in a post on Twitter (I’ve tried to find the original again to credit the author, but no luck), and it changed how I think about what to work on first when I build software.</p>

<figure>

<img alt="Software quality broken down into functionality, reliability, usability, maintainability, performance, security, compatibility and transferability" src="/assets/2022-04-09-software-development-quality/sw-priority.png">
</figure>
<p>Before this, I never thought about priorities at all. I just built things, and I kept falling into the same traps. For example, when building a data pipeline, I’d get obsessed with <strong>performance</strong> and spend my time making a job faster. And when building a web app, I’d get obsessed with the UI and UX instead, so the layout and how it feels to click around got all my attention.</p>
<p>Neither of those is wrong to care about. But while I was deep in one branch, I’d forget the tree had any other branches, and this picture made that obvious to me the first time I saw the whole tree laid out at once.</p>
<p>Performance is one branch out of eight, and usability is another one. The two at the top, functionality and reliability, are the ones everything else depends on. A pipeline that’s fast but produces the wrong numbers is worse than a slow one, because people trust its output and make decisions with it. And a nice-looking web app that falls over the moment someone uses it slightly differently than I expected isn’t done, no matter how good the screenshots look.</p>
<p>So now the order in my head is roughly this. First, the software does the right thing, completely and correctly. Second, the software keeps doing the right thing, which means it tolerates failures and I can recover from a break. Only then do I get to everything else, including the parts I personally enjoy the most, like making it fast or making it pretty.</p>
<p>If the map looks familiar, it’s very close to ISO/IEC 25010, the standard software product quality model, which has almost the same eight characteristics.</p>
<p>It’s a simple picture, but it set up the mindset I build software with now. Before I start optimizing or polishing anything, I check whether the first two branches are actually done.</p>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Notes From an Advanced Apache Spark Training</title>
    <link
      href="https://hattajr.github.io/2021/12/10/advanced-spark-training.html"
      rel="alternate" type="text/html"
      title="Notes From an Advanced Apache Spark Training" />
    <published>2021-12-10T00:00:00+00:00</published>
    <updated>2021-12-10T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2021/12/10/advanced-spark-training</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[I've been using Spark for a while now, but mostly from the outside. I knew which settings to change when a job was slow, and I even wrote some of them down back in August. But I couldn't really explain why those settings worked.]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2021/12/10/advanced-spark-training.html">
      <![CDATA[
<header>
  <h1>Notes From an Advanced Apache Spark Training</h1>
  <time class="meta" datetime="2021-12-10">Dec 10, 2021</time>
</header>
<p>I’ve been using Spark for a while now, but mostly from the outside. I knew which settings to change when a job was slow, and I even <a href="/2021/08/05/spark-configuration-experiences.html">wrote some of them down</a> back in August. But I couldn’t really explain <em>why</em> those settings worked.</p>
<p>So I went looking for something that explains what Spark is actually doing, and I found <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y">Advanced Apache Spark Training</a> by Sameer Farooqui from Databricks. It’s a full-day class recorded at Spark Summit East in New York in March 2015. I’m going to say it plainly: this is one of the best resources for learning Spark I’ve found.</p>
<p>Don’t let the word “advanced” scare you off. He starts at what an RDD is and builds up from there to how Spark runs on a cluster, where the memory goes, how a job turns into stages and tasks, how PySpark works, how the shuffle was rebuilt to win the 100 TB sort benchmark, etc. Everything is drawn as machines, JVMs, task slots and partitions, so you end up with a picture in your head instead of a list of rules.</p>
<p>It’s also almost six hours long. If that’s too much, watch the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=1620s">RDD section</a>, the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=4810s">cluster section</a> and the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=11970s">jobs, stages and tasks section</a> first. You can skip the Databricks Cloud demo (roughly 1:00:00 to 1:20:00) and the lab break (roughly 4:04:00 to 4:30:00). The <a href="https://web.archive.org/web/2015/https://spark-summit.org/wp-content/uploads/2015/03/SparkSummitEast2015-AdvDevOps-StudentSlides.pdf">slides</a> are still on the Internet Archive, too.</p>
<p>Below are my notes in the order of the class, with a link to where each part starts, and a checklist of the tips at the end. One warning before you read them. This is Spark 1.2 and 1.3, so a few of the settings he shows don’t exist anymore. The ideas mostly still hold, and I put what changed by Spark 3.2 in the footnotes so the notes themselves stay true to the talk.</p>
<section id="Why-It's-Faster-Than-MapReduce">

<h2><a href="#Why-It's-Faster-Than-MapReduce">Why It’s Faster Than MapReduce</a></h2>
<p>The <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=314s">first part</a> is a tour of the ecosystem, and the useful bit is the argument for Spark. In MapReduce, a real analysis is a chain of jobs, and each job writes its result back to HDFS so the next one can read it. His example is k-means with Mahout, which needs about 17 MapReduce jobs even for 5 MB of data. Spark keeps the data in memory between steps, so one Spark job can be “20 maps and 15 reducers and a whole bunch of joins”.</p>
<p>He backs this with the speeds he uses for the whole class:</p>
<table>
<tr>
<th>Where the data is</th>
<th>Rough read speed</th>
</tr>
<tr>
<td>Memory</td>
<td>10 GB/s</td>
</tr>
<tr>
<td>SSD</td>
<td>600 MB/s</td>
</tr>
<tr>
<td>Spinning disk</td>
<td>100 MB/s</td>
</tr>
<tr>
<td>Another node, same rack</td>
<td>125 MB/s (1 Gb/s)</td>
</tr>
<tr>
<td>Another rack</td>
<td>0.1 Gb/s</td>
</tr>
</table>
<p>Spark doesn’t replace Hadoop, by the way. Hadoop is three projects (HDFS, YARN and MapReduce), and Spark replaces only the MapReduce engine. It reads almost anything through Hadoop’s InputFormat, which is why all of Spark’s I/O code was only ~400 lines for a long time.</p>
<p>Fun fact from this part: Spark started as a sample app for Mesos. Matei Zaharia wrote it to “spark” an ecosystem of apps on top of Mesos, and it outgrew Mesos.</p>
<p>He also recommends a few resources here that I’m adding to my list. The <em>Learning Spark</em> book (he says it has information you won’t find anywhere else), the Apache Spark user mailing list (most questions get answered within hours), <a href="https://spark-packages.org/">Spark Packages</a> for anything that isn’t in Spark itself, and the <a href="https://www.usenix.org/system/files/conference/nsdi12/nsdi12-final138.pdf">2012 RDD paper</a>.</p>
</section>
<section id="A-Partition-Is-a-Task-Is-a-Thread">

<h2><a href="#A-Partition-Is-a-Task-Is-a-Thread">A Partition Is a Task Is a Thread</a></h2>
<p>The <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=1620s">RDD section</a> has the most useful idea in the whole class, and it’s also the simplest. An RDD is split into partitions, each partition needs one task, and each task is one thread in an executor. So an RDD with 5 partitions can use 5 cores and that’s it, even if your cluster has 1,000 of them. His rule of thumb is that 1,000 to 10,000 partitions in an RDD is perfectly normal, and a 100-node cluster needs at least 100.</p>
<p>You make an RDD by reading from storage or by calling <code>parallelize</code> on a collection in the driver. <code>parallelize</code> needs the whole dataset in the driver’s memory, so it’s only for prototypes and tests.</p>
<p>When you read a file from HDFS, you get one partition per block. A <code>filter</code> keeps the same number of partitions as its parent, even when some of them end up empty. This is why the usual pattern is to read, filter, and then <code>coalesce</code> or <code>repartition</code> to something that fits your cluster.</p>
<p>None of this runs until you call an action. Transformations are lazy, so reading and filtering only build a DAG of metadata. Spark doesn’t even check that the input file <em>exists</em> until you call <code>count</code> or <code>collect</code>.</p>
<p>And be careful with <code>collect</code>, because it pulls every partition into a driver that probably has a gig or two of heap. On anything big, save it or <code>sample</code> it first.</p>
<p>Caching is lazy too. Calling <code>cache()</code> only marks the RDD, and it’s stored the first time an action materializes it. The beginner mistake he calls out is caching the base RDD right after reading it. Do your filters and maps first, and cache the cleaned RDD that several actions actually reuse. A production app has hundreds of RDDs, so be picky.</p>
<p>It also doesn’t have to fit in memory. If only 30 of 100 partitions fit, 30 are cached and the other 70 get recomputed when they’re needed, so performance degrades instead of falling over.</p>
<p>Every RDD is defined by five things. It has a list of partitions, its dependencies on parent RDDs, a function to compute one partition from the parents, optional preferred locations (for example, where an HDFS block lives), and an optional partitioner for key/value data. That’s the whole interface, and it’s what makes recovery work.</p>
<p>A <code>HadoopRDD</code> has one partition per block and no parents. A filtered RDD has its parent’s partitions and a one-to-one dependency on it. A joined RDD has a shuffle dependency on both parents and a <code>HashPartitioner</code>. If you lose a partition, Spark recomputes just that partition from this information.</p>
<p>He also wants you to know the <em>type</em> of each RDD in your DAG, even if you have to draw it on paper at first. A <code>ShuffledRDD</code> tells you a shuffle happened right before it, and shuffles are the expensive part.</p>
<p>A few more tips from this part that I really liked:</p>
<ul>
<li>
If the docs describe a transformation in two sentences, go read the Scala source. It often has a full paragraph of comments on the same thing.
</li>
<li>
If you write to an external database, don’t open a connection per element. Use <code>foreachPartition</code> or <code>mapPartitions</code>, open one connection per partition, push everything through it, and close it.
</li>
<li>
Some connectors push work down to the source. The Cassandra connector, for example, can select only the columns you need and push a <code>where</code> filter into Cassandra, instead of reading the whole table into Spark and filtering there.
</li>
<li>
With Cassandra, <code>spark.cassandra.input.split.size</code> sets the rows per partition (100,000 by default). With very wide rows, lower it, for example to 2,000. He says getting this right is “more of an art than a science”.
</li>
</ul>
</section>
<section id="Four-Ways-to-Run-Spark">

<h2><a href="#Four-Ways-to-Run-Spark">Four Ways to Run Spark</a></h2>
<p>He opens the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=4810s">cluster section</a> by saying this was the hardest thing for him to figure out when he joined Databricks. Spark runs in local mode, Standalone, YARN or Mesos, and the architecture is different in each one.</p>
<p>What helped me was his summary at the end. There’s <em>always</em> a driver and executors, and tasks always run in executors. The only thing that changes is who decides where the executors go and who starts them.</p>
<table>
<tr>
<th>Mode</th>
<th>Decides where executors run</th>
<th>Starts them</th>
</tr>
<tr>
<td>Local</td>
<td>nobody</td>
<td>you</td>
</tr>
<tr>
<td>Standalone</td>
<td>Standalone Master</td>
<td>Worker JVM</td>
</tr>
<tr>
<td>YARN</td>
<td>YARN Application Master</td>
<td>Node Manager</td>
</tr>
<tr>
<td>Mesos</td>
<td>Mesos Master</td>
<td>Mesos Slave</td>
</tr>
</table>
<p>Compared to MapReduce, parallelism in Spark comes from threads and not processes. MapReduce starts a JVM per map or reduce task and has fixed map and reduce slots, so the reduce slots sit idle during a long map phase. Spark usually runs one executor JVM per machine with generic slots that can run any task. Handing a task to a slot is a thread handoff, not a JVM that takes a second to start.</p>
<p>The scheduler that matters most is the one in the driver, not the cluster manager. The cluster manager places executors. The driver places every task, and it tries to put each one on the executor that already holds its partition.</p>
<section id="Local-Mode">

<h3><a href="#Local-Mode">Local Mode</a></h3>
<p><strong><strong>Oversubscribe your cores.</strong></strong> This is the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=5340s">setting tip</a> he spends the most time on. <code>local</code> gives you one thread, <code>local[N]</code> gives you N, and <code>local[*]</code> gives you one per logical core. If you have 6 free cores, don’t give Spark 6 slots. Start at 12 or 18, because threads aren’t pinned to cores and tasks spend part of their time waiting on I/O. He says some clients doubled their performance with just this change.</p>
<p>There are also about 20 internal threads in every executor, around 8 of them just for shuffle, so matching slots to cores exactly doesn’t work anyway. I’m not sure how well this holds for purely CPU-bound jobs and he doesn’t really say, so I’d measure it on my own workload before trusting the 2x.</p>
</section>
<section id="Standalone-Mode">

<h3><a href="#Standalone-Mode">Standalone Mode</a></h3>
<p>In <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=5910s">Standalone mode</a>, the setting to understand is <code>SPARK_LOCAL_DIRS</code>. It’s where Spark writes both spilled cache partitions and shuffle files, and you can’t split the two. Use SSDs if you have them (even one helps), and list one mount point per disk as JBOD and not RAID, so Spark writes to all of them in parallel.</p>
<p>The Master and Worker JVMs are only the resource manager. They need about a gig each. The workers start executors and restart them if they crash, the Master restarts crashed workers, and <code>--supervise</code> makes the Master restart the driver too. The Master can be made highly available with ZooKeeper, and you can even add masters while apps are running.</p>
<p>The other Standalone names are confusing. <code>SPARK_WORKER_CORES</code> and <code>SPARK_WORKER_MEMORY</code> aren’t what the worker uses. They’re what it can <em>hand out</em> to executors. The worker’s own heap is <code>SPARK_DAEMON_MEMORY</code>.</p>
<p>For each app, you set <code>spark.cores.max</code> for the whole app and <code>spark.executor.memory</code> per executor. If you don’t, the first app greedily takes the entire cluster and starves everything after it. Note that <code>spark.cores.max</code> is the total for the app, not per executor. Set it to 100 on a 10-node cluster and you get 10 executors with 10 cores each, and since you should oversubscribe here too, he’d make it 200. And watch the Master UI for this misconfiguration he shows. An executor took all 3 cores of a worker but only 512 MB of its 7.7 GB of memory. If you take all the cores, take the memory too.<a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a></p>
</section>
<section id="The-Spark-UI">

<h3><a href="#The-Spark-UI">The Spark UI</a></h3>
<p>His walkthrough of the <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=7380s">Spark UI</a> has a few things I didn’t know. A Spark app is made of jobs (one per action), a job of stages, and a stage of tasks.</p>
<p>Caching an RDD and opening the Storage tab shows the size of each partition, which is a cheap way to spot skew such as 90% of the data in one of 50 partitions. It’s also a way to count partitions, along with <code>getNumPartitions</code>. And deserialized Java objects take 2 to 3 times the file size, so a 100 MB file can need 200 to 300 MB of cache.</p>
<p>Settings can come from four places, and the precedence goes <code>SparkConf</code> in your code, then <code>spark-submit</code> flags, then <code>spark-defaults.conf</code> and <code>spark-env.sh</code>, then the built-in defaults. The Environment tab shows which value actually won.</p>
<p>The Executors tab has a thread dump button, which works as a poor man’s profiler if you click it a few times over a minute and look for your own code in the stacks.</p>
</section>
<section id="YARN">

<h3><a href="#YARN">YARN</a></h3>
<p>On <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=8080s">YARN</a>, the Resource Manager starts an Application Master in a container, and the Application Master asks for the containers your executors run in. After that, the Resource Manager is out of the loop. If it crashes, running apps keep going, but they can’t get new containers.</p>
<p>Client mode runs the driver on your machine. This is what the shells use, so closing your laptop kills the app. Cluster mode runs the driver inside the Application Master so you can submit and walk away. Unlike Standalone, YARN lets you set <code>--num-executors</code>, <code>--executor-memory</code> and <code>--executor-cores</code> directly.</p>
<p>One gotcha with YARN is data locality. If you start 20 executors on a 100-node cluster, they may land on nodes that don’t have your HDFS blocks. Starting one executor per node avoids that.</p>
<p>Once an app finishes, its driver and its UI are gone. The Spark history server keeps the UI of past apps, so you can still look at how yesterday’s jobs ran.</p>
</section>
<section id="Dynamic-Allocation">

<h3><a href="#Dynamic-Allocation">Dynamic Allocation</a></h3>
<p>YARN also has <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=8820s">dynamic allocation</a>, which he says had no docs yet, so he figured it out from the comments in <code>ExecutorAllocationManager.scala</code>. With <code>spark.dynamicAllocation.enabled=true</code>, the app starts at <code>minExecutors</code> and adds executors once tasks have waited longer than <code>schedulerBacklogTimeout</code>. It keeps adding more each <code>sustainedSchedulerBacklogTimeout</code>, growing exponentially up to <code>maxExecutors</code>, and it removes executors that have been idle for <code>executorIdleTimeout</code>.</p>
</section>
</section>
<section id="Where-the-Memory-Goes">

<h2><a href="#Where-the-Memory-Goes">Where the Memory Goes</a></h2>
<p>His <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=10080s">sizing rules</a> are to give Spark at most ~75% of a machine’s memory, give each executor at least 8 GB, and keep a heap under about 40 to 45 GB on a normal JVM or the GC pauses get bad. The 75% isn’t arbitrary. If HDFS or Cassandra runs on the same machine, they need memory too, and giving executors everything leads to swapping.</p>
<p><code>cache()</code> is just <code>persist(MEMORY_ONLY)</code>, which stores deserialized objects and drops partitions that don’t fit. <code>MEMORY_ONLY_SER</code> stores one byte array per partition, which is smaller and easier on the GC but costs CPU. <code>MEMORY_AND_DISK</code> spills what doesn’t fit into the local dirs, always serialized. <code>DISK_ONLY</code> does what it says, the <code>_2</code> levels keep two copies on two executors, and <code>OFF_HEAP</code> stored data outside the JVM (in Tachyon at the time).</p>
<p>His advice for picking one:</p>
<ul>
<li>
Use <code>MEMORY_ONLY</code> if it fits, then try <code>MEMORY_ONLY_SER</code> with a fast serializer.
</li>
<li>
Only spill to disk if the partitions are expensive to compute, since recomputing is often as fast as reading from disk.
</li>
<li>
Use the replicated levels sparingly. Spark isn’t HDFS, and lineage is the backup.
</li>
</ul>
<p>The cache is LRU and you can’t pin anything, so caching something huge evicts everything older. Use <code>unpersist()</code> when you’re done with an RDD. If an executor dies, its cache is gone and nothing rebuilds it in the background. Partitions only come back when something needs them again. In PySpark, cached data is always pickled anyway, so <code>_SER</code> or not makes no difference there.</p>
<p>The thing in this section that surprised me most: <strong><strong>shuffle output stays on disk even if you never cache anything.</strong></strong> Run the same action twice on a long DAG, and the second run only goes back to the last shuffle and reuses the map output files still sitting in the local dirs. That explains why a re-run can be so much faster, and why the UI shows “skipped” stages.</p>
<p>He also shows <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=11460s">how the executor heap was split</a>. It was 60% for cached RDDs with <code>spark.storage.memoryFraction</code>, 20% for shuffle buffers with <code>spark.shuffle.memoryFraction</code>, and the remaining 20% for your own code.<a id="fnref2" href="#fn2" role="doc-noteref"><sup>2</sup></a></p>
<p>If executors are getting killed for memory, figure out which of those three caused it. Running out during a shuffle means giving shuffle more. Running out in your own code means shrinking the other two, because there’s no setting for user memory itself. If you barely cache anything, he says you can drop the cache fraction to 10 or 20%, but not to zero. To see how much memory an RDD takes, look at the Storage tab, or at the driver log, which has a line like <code>Added rdd_0_1 in memory on mbk.local:50311 (size: 717.5 KB, free: 332.3 MB)</code> for every cached partition.</p>
<p>For <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=11670s">serialization and GC</a>, the default Java serializer is slow and it’s used for shuffles, spills, serialized caching, broadcasts, etc. So switch to Kryo in production (<code>spark.serializer=org.apache.spark.serializer.KryoSerializer</code>), register your classes, and raise the Kryo buffer if your objects are large.</p>
<p>GC cost depends on the <em>number</em> of objects and not their size, which makes an array of ints a lot cheaper than a linked list. You can measure it by starting executors with <code>-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps</code>. The parallel GC is fine for batch, but for streaming he says to use CMS or G1 to avoid long stop-the-world pauses.</p>
</section>
<section id="Jobs-Stages-and-Tasks">

<h2><a href="#Jobs-Stages-and-Tasks">Jobs, Stages and Tasks</a></h2>
<p>He calls <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=11970s">this section</a> the most important one for writing fast Spark code, and I agree. An action triggers a job. The DAG scheduler in the driver cuts the DAG into stages and hands each stage to the task scheduler, which sends individual tasks to executors and doesn’t know anything about stages at all. If you want to go deeper, he recommends Matei Zaharia’s “Spark Internals” talk on YouTube.</p>
<p>Independent stages can run at the same time, but a stage that needs both of them waits for the slower one. So if one parent stage takes 3 minutes and the other takes 17, move work into the fast one if you can.</p>
<p>Where the stages get cut comes down to <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=12240s">narrow and wide dependencies</a>. A dependency is <em>narrow</em> when each parent partition feeds at most one child partition. That covers <code>map</code>, <code>filter</code>, <code>union</code>, and a join of two RDDs that are already partitioned the same way. It’s <em>wide</em> when one parent partition feeds many children, for example <code>groupByKey</code> or a join of RDDs that aren’t co-partitioned. Wide means shuffle.</p>
<p>Narrow steps get pipelined, so one thread reads a block, maps it and filters it all within one stage. They’re also cheap to recover, because losing a partition means recomputing one parent partition. After a wide step, losing a partition might mean rereading the whole input. This is why he suggests caching the RDD right after an expensive shuffle. And if you do cache it, it’s worth using <code>MEMORY_AND_DISK</code>, so an evicted partition comes back from local disk instead of from the whole input.</p>
<p>Another one from this part: if a transformation is going to shuffle anyway, pass <code>numPartitions</code> to it. Otherwise the child inherits its parent’s partition count, which may not be what you want after the shuffle.</p>
<p>One might ask why the famous diagram in the RDD paper has no stage boundary at the <code>groupBy</code> right before the join. He asked Matei the same thing (“did you make a massive mistake in your white paper?”), and the answer is that the shuffled RDD is never really materialized. The join reads the shuffle’s map output files directly.</p>
<p>He also does a quiz from a Cloudera blog post by Sandy Ryza. How many stages does this need?</p>

<figure class="code-block">


<pre><code><span class="line">sc.textFile(<span class="hl-string">&quot;someFile.txt&quot;</span>) \</span>
<span class="line">    .<span class="hl-built_in">map</span>(map_func) \</span>
<span class="line">    .flatMap(flat_map_func) \</span>
<span class="line">    .<span class="hl-built_in">filter</span>(filter_func) \</span>
<span class="line">    .count()</span></code></pre>

</figure>
<p>Just one, since every step is narrow. Add a <code>groupByKey</code> and a <code>join</code> and you get three.</p>
<p><code>toDebugString</code> prints the lineage, and each level of indentation is a stage boundary. This is what the lineage of a PySpark word count looks like on Spark 3, with the call sites trimmed:</p>

<figure class="code-block">


<pre><code><span class="line">(4) PythonRDD[9] at RDD at PythonRDD.scala:53</span>
<span class="line"> |  MapPartitionsRDD[8] at mapPartitions at PythonRDD.scala:145</span>
<span class="line"> |  ShuffledRDD[7] at partitionBy at ...</span>
<span class="line"> +-(4) PairwiseRDD[6] at reduceByKey at &lt;stdin&gt;:3</span>
<span class="line">    |  PythonRDD[5] at reduceByKey at &lt;stdin&gt;:3</span>
<span class="line">    |  log.txt MapPartitionsRDD[1] at textFile at ...</span>
<span class="line">    |  log.txt HadoopRDD[0] at textFile at ...</span></code></pre>

</figure>
<p>The <code>HadoopRDD</code> followed by a map is something you get for free when reading text. That map converts Hadoop’s <code>Writable</code> types into normal ones.</p>
<p>He has a few rules for spotting a shuffle:</p>
<ul>
<li>
<code>repartition</code>, <code>join</code>, <code>cogroup</code> and anything named <code>*By</code> or <code>*ByKey</code> probably shuffle.
</li>
<li>
Passing <code>numPartitions</code> usually means a shuffle too, since <code>distinct</code> is a <code>reduceByKey</code> inside.
</li>
<li>
<code>groupByKey</code> and friends call <code>combineByKey</code>, which shuffles.
</li>
<li>
<code>repartition(n)</code> is literally <code>coalesce(n, shuffle=True)</code>. So use <code>coalesce</code> to reduce partitions. A <code>repartition</code> to the <em>same</em> number is still useful, since it rebalances skewed partitions.
</li>
</ul>
<p>And if you only change the values of a key/value RDD, tell Spark the keys are untouched. Otherwise it drops the partitioner and the next join shuffles again. He shows this as <code>map(..., preservesPartitioning=True)</code>.<a id="fnref3" href="#fn3" role="doc-noteref"><sup>3</sup></a></p>
<p>Someone asked how many transformations an app should have. His answer is that a few thousand is fine. Unlike MapReduce, there’s no reason to split a pipeline into many apps, so keep it as one Spark app.</p>
</section>
<section id="Salting-a-Skewed-Shuffle">

<h2><a href="#Salting-a-Skewed-Shuffle">Salting a Skewed Shuffle</a></h2>
<p>This part isn’t in the talk. It’s the technique I reach for when a shuffle is unbalanced, and the class finally gave me the words for <em>why</em> it works, so I want it in the same place as everything else.</p>
<p>Here’s the problem. A shuffle sends every record with the same key to the same partition, because that’s what a <code>HashPartitioner</code> does. One partition is one task, and one task is one thread. So if one key has 40% of the records, one thread gets 40% of the work, and the stage can’t finish until that one task does. The Spark UI makes this obvious once you know to look. In the Stages tab, the max task duration is minutes while the median is seconds, and one task shows a huge shuffle read. Adding executors doesn’t help at all, since the extra cores just sit there waiting for that one thread.</p>
<p>Before fixing anything, I check which keys are hot:</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">from</span> pyspark.sql <span class="hl-keyword">import</span> functions <span class="hl-keyword">as</span> F</span>
<span class="line"></span>
<span class="line">(</span>
<span class="line">    df.groupBy(<span class="hl-string">&quot;user_id&quot;</span>)</span>
<span class="line">    .count()</span>
<span class="line">    .orderBy(F.desc(<span class="hl-string">&quot;count&quot;</span>))</span>
<span class="line">    .show(<span class="hl-number">10</span>)</span>
<span class="line">)</span></code></pre>

</figure>
<p>Salting splits a hot key into several keys. You add a random number from <code>0</code> to <code>N - 1</code> (the salt) to the key, so <code>user_42</code> becomes <code>(user_42, 0)</code> through <code>(user_42, 7)</code>, and the hash partitioner spreads those 8 keys over up to 8 tasks. Then you undo the salt at the end. How you undo it depends on what you’re doing.</p>
<p>For an <strong><strong>aggregation</strong></strong>, you aggregate twice. First by the salted key, which runs in parallel across the salts, and then again by the real key, which only has <code>N</code> partial results per key left to combine:</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">import</span> random</span>
<span class="line"></span>
<span class="line">SALTS = <span class="hl-number">8</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">def</span> <span class="hl-title function_">add_salt</span>(<span class="hl-params">kv</span>):</span>
<span class="line">    key, value = kv</span>
<span class="line">    <span class="hl-keyword">return</span> (key, random.randrange(SALTS)), value</span>
<span class="line"></span>
<span class="line"></span>
<span class="line">partial = pairs.<span class="hl-built_in">map</span>(add_salt).reduceByKey(<span class="hl-keyword">lambda</span> a, b: a + b)</span>
<span class="line">totals = (</span>
<span class="line">    partial.<span class="hl-built_in">map</span>(<span class="hl-keyword">lambda</span> kv: (kv[<span class="hl-number">0</span>][<span class="hl-number">0</span>], kv[<span class="hl-number">1</span>]))</span>
<span class="line">    .reduceByKey(<span class="hl-keyword">lambda</span> a, b: a + b)</span>
<span class="line">)</span></code></pre>

</figure>
<p>This only works for aggregations you can combine in pieces, such as sums, counts, min, max, etc. A median doesn’t split like this. And to be fair to <code>reduceByKey</code>, it already combines on the map side before the shuffle, so a hot key in a plain sum hurts less than you’d think. Salting pays off most with <code>groupByKey</code>-style work and with joins, where nothing gets combined before the shuffle.</p>
<p>For a <strong><strong>join</strong></strong>, the salt has to match on both sides. You salt the big, skewed side randomly, and you copy each row of the other side once per salt value, so every salted key still finds its match:</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">from</span> pyspark.sql <span class="hl-keyword">import</span> functions <span class="hl-keyword">as</span> F</span>
<span class="line"></span>
<span class="line">SALTS = <span class="hl-number">8</span></span>
<span class="line"></span>
<span class="line">events_salted = events.withColumn(</span>
<span class="line">    <span class="hl-string">&quot;salt&quot;</span>, (F.rand() * SALTS).cast(<span class="hl-string">&quot;int&quot;</span>)</span>
<span class="line">)</span>
<span class="line">salts = F.array([F.lit(i) <span class="hl-keyword">for</span> i <span class="hl-keyword">in</span> <span class="hl-built_in">range</span>(SALTS)])</span>
<span class="line">users_salted = users.withColumn(<span class="hl-string">&quot;salt&quot;</span>, F.explode(salts))</span>
<span class="line"></span>
<span class="line">joined = events_salted.join(</span>
<span class="line">    users_salted, [<span class="hl-string">&quot;user_id&quot;</span>, <span class="hl-string">&quot;salt&quot;</span>]</span>
<span class="line">).drop(<span class="hl-string">&quot;salt&quot;</span>)</span></code></pre>

</figure>
<p>The cost is that the small side is now <code>N</code> times bigger and goes through the shuffle too. So I keep <code>N</code> small (start around the skew ratio of the hottest key to an average key, then measure), and when only a few keys are hot, I salt only those keys and give every other row a salt of <code>0</code>. If the small side fits in memory, skip all of this and broadcast it instead, like the talk shows in the next section. No shuffle means no skew.</p>
<p>On Spark 3, it’s also worth trying adaptive query execution before salting by hand. With <code>spark.sql.adaptive.enabled</code> on, Spark can split skewed partitions of a sort-merge join on its own.<a id="fnref4" href="#fn4" role="doc-noteref"><sup>4</sup></a> It only covers joins, though, so I still salt aggregations myself.</p>
</section>
<section id="Broadcasts-and-Accumulators">

<h2><a href="#Broadcasts-and-Accumulators">Broadcasts and Accumulators</a></h2>
<p>Without a <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=14100s">broadcast</a>, anything your closure uses is shipped with every task. A broadcast variable is sent once per executor and shared by its tasks, which is great for lookup tables, for a big feature vector in ML, or for avoiding a shuffle join when one side is small.</p>
<p>The implementation is fun. Broadcasts used to be plain HTTP from the driver, which made the driver’s network card the bottleneck. Now they use a BitTorrent-like protocol where the value is cut into ~4 MB chunks and executors fetch chunks from <em>each other</em>.</p>
<p>Accumulators are basically Hadoop counters, for things like counting blank lines or corrupt records. Tasks can only add to them, only the driver can read the value, and you can watch them in the Spark UI while a job runs. He says the <em>Learning Spark</em> book has a whole chapter on their edge cases.</p>

<figure class="code-block">


<pre><code><span class="line">lookup = sc.broadcast({<span class="hl-string">&quot;Error&quot;</span>: <span class="hl-number">3</span>, <span class="hl-string">&quot;Warn&quot;</span>: <span class="hl-number">2</span>, <span class="hl-string">&quot;Info&quot;</span>: <span class="hl-number">1</span>})</span>
<span class="line">levels = lines.<span class="hl-built_in">map</span>(<span class="hl-keyword">lambda</span> line: line.split(<span class="hl-string">&quot;,&quot;</span>)[<span class="hl-number">0</span>])</span>
<span class="line">severity = levels.<span class="hl-built_in">map</span>(<span class="hl-keyword">lambda</span> level: lookup.value[level]).<span class="hl-built_in">sum</span>()</span>
<span class="line"></span>
<span class="line">blank_lines = sc.accumulator(<span class="hl-number">0</span>)</span>
<span class="line"></span>
<span class="line"><span class="hl-keyword">def</span> <span class="hl-title function_">count_blank</span>(<span class="hl-params">line</span>):</span>
<span class="line">    <span class="hl-keyword">global</span> blank_lines</span>
<span class="line">    <span class="hl-keyword">if</span> <span class="hl-keyword">not</span> line.strip():</span>
<span class="line">        blank_lines += <span class="hl-number">1</span></span>
<span class="line"></span>
<span class="line">lines.foreach(count_blank)</span>
<span class="line"><span class="hl-built_in">print</span>(blank_lines.value)</span></code></pre>

</figure>
</section>
<section id="PySpark-Is-Mostly-a-JVM-Program">

<h2><a href="#PySpark-Is-Mostly-a-JVM-Program">PySpark Is Mostly a JVM Program</a></h2>
<p>The <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=16260s">PySpark section</a> changed how I think about PySpark the most. PySpark runs normal CPython, so any library with C extensions (NumPy, NLTK, etc.) just works. The <code>SparkContext</code> you get in Python is only a controller. It starts a normal JVM driver and talks to it over Py4J, which is fine for commands but slow for data.</p>
<p>Each executor JVM then starts <code>daemon.py</code>, which forks one Python worker per task slot. Data moves between the executor and those workers over a custom high-throughput pipe.</p>
<p>Reading and writing data happens in the <em>JVM</em>, so PySpark reuses all of Spark’s I/O code instead of reimplementing every source in Python. That’s how the first version of PySpark was only a few thousand lines. Your functions are pickled with <code>cloudpickle</code>, sent through the driver JVM to the executors, unpickled in the Python workers, and run there. Even <code>collect</code> avoids Py4J. The driver JVM writes the results to local files and Python reads them back, and big broadcasts go the same way.</p>
<p>The practical takeaway is that your own Python code runs at Python speed, but MLlib, shuffles, Spark SQL and DataFrames run inside the JVM. A DataFrame job from Python is about as fast as one from Scala.</p>
<p>Two settings from this part are worth knowing. You can switch the interpreter to PyPy with <code>PYSPARK_PYTHON=pypy</code> and <code>PYSPARK_DRIVER_PYTHON=pypy</code>, with speedups from 1.05x on a sort to 48x on a stats job, as long as you don’t need C extensions. And each Python worker gets 512 MB by default, which you can raise with <code>spark.python.worker.memory</code>.</p>
</section>
<section id="Sorting-100-TB-on-206-Machines">

<h2><a href="#Sorting-100-TB-on-206-Machines">Sorting 100 TB on 206 Machines</a></h2>
<p>The <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=17340s">100 TB sort</a> is his favorite part, and it’s easy to see why. Sorting is the purest test of the shuffle, since sorting 100 TB means ~500 TB of disk I/O and ~200 TB over the network. The rules also said the data starts on disk, so no caching was allowed at all.</p>
<table>
<tr>
<th></th>
<th>Hadoop (2013)</th>
<th>Spark (2014)</th>
<th>Spark, 1 PB</th>
</tr>
<tr>
<td>Data</td>
<td>102.5 TB</td>
<td>100 TB</td>
<td>1,000 TB</td>
</tr>
<tr>
<td>Time</td>
<td>72 min</td>
<td>23 min</td>
<td>234 min</td>
</tr>
<tr>
<td>Nodes</td>
<td>2,100</td>
<td>206</td>
<td>190</td>
</tr>
<tr>
<td>Cores</td>
<td>50,400</td>
<td>6,592</td>
<td>6,080</td>
</tr>
<tr>
<td>Per node</td>
<td>0.67 GB/min</td>
<td>20.7 GB/min</td>
<td>22.5 GB/min</td>
</tr>
</table>
<p>That’s the same data sorted 3x faster on 10x fewer machines, in the public cloud. Spark tied for first with TritonSort from UC San Diego, a system built to do nothing but sort.</p>
<p>The setup was 206 <code>i2.8xlarge</code> EC2 machines with one big executor each, 32 slots, 8 SSDs and about 9.5 Gb/s between two random nodes. They used HDFS short-circuit reads, turned off speculative execution and compression, set locality wait to infinite, set <code>spark.shuffle.spill=false</code> so reducers kept everything in memory, and used <code>sun.misc.Unsafe</code> to keep data off the heap so GC never ran. HDFS blocks were 3.6 GB, which made 28,000 blocks, so the maps and the reduces each ran in 5 waves. He’s very clear that this is a benchmark setup and not something to copy into production.</p>
<p>The <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=18060s">engineering behind it</a> was three changes. The first is the new shuffle. The old hash-based shuffle had each map task keep one open file per reducer and write into all of them, which falls apart around 10,000 reducers because of the per-file buffers. The new <em>sort-based</em> shuffle (SPARK-2045) sorts a bit of the input at a time, writes one file at a time, and merge-sorts them into a single file indexed by reducer. It’s been tested with 250,000 reducers. He adds that for a small number of partitions the hash-based one might still be a bit faster, since it skips the merge.<a id="fnref5" href="#fn5" role="doc-noteref"><sup>5</sup></a></p>
<p>The second is the Netty transport (SPARK-2468), which sends map output straight from the file to the network card instead of copying it through a kernel buffer and the executor first. The third is the external shuffle service (SPARK-3796). The Worker or Node Manager serves map output instead of the executor that wrote it, which takes load off busy executors. It’s also <em>required</em> for dynamic allocation, since otherwise a removed executor’s shuffle files would disappear before the reducers fetched them.</p>
<p>The result saturated everything. The map phase hit ~3 GB/s of disk per node and the reduce phase hit ~1.1 GB/s of network per node, which is the whole 10 Gb link. On the reducer side, there’s also a limit on how much data each reducer pulls at once (48 MB by default) so it doesn’t flood its own network card.</p>
</section>
<section id="Streaming-in-Small-Batches">

<h2><a href="#Streaming-in-Small-Batches">Streaming in Small Batches</a></h2>
<p>The class ends with <a href="https://www.youtube.com/watch?v=7ooZ4S7Ay6Y&amp;t=19860s">Spark Streaming</a>.<a id="fnref6" href="#fn6" role="doc-noteref"><sup>6</sup></a> A DStream is a sequence of RDDs, one per batch interval, and transformations you apply to the DStream run on every RDD it produces. The point is that you write batch and streaming code with the same API instead of MapReduce plus a separate Storm codebase. In this version, the Python API couldn’t read from Kafka or Flume yet, only files and sockets.</p>
<p>Batches can be as small as ~500 ms. He says that covers 90 to 95% of real-time use cases, and the rest (high-frequency trading, for example) need something like Storm.</p>
<p>A receiver is a long-running task that permanently takes one slot, which is why a streaming app in local mode needs at least <code>local[2]</code>. The receiver cuts incoming data into a block every 200 ms and replicates each block to a second executor. So with a 600 ms batch interval, each batch’s RDD has 3 partitions. The 200 ms block interval is configurable, and it’s effectively your partition size, but he says Tathagata Das (who wrote Spark Streaming) told him you probably don’t need to touch it. <code>local[2]</code> is also only the minimum. In general, you want way more threads than that.</p>
<p>The rule that matters is that processing time has to stay below the batch interval, or you fall further behind with every batch. The easy fix is a <em>smaller</em> batch interval with less data per batch, down to about 500 ms. You can also repartition early to get more parallelism. And one receiver is one thread, so to ingest more you run several receivers, one per Kafka topic for example, and <code>union</code> the DStreams.</p>
<p>The streaming tab in the UI shows processing time percentiles over the last 100 batches, the input rate, and each receiver, which is where you’d see that you’re falling behind.</p>
<p>He finishes with the API. <code>transform</code> gives you any RDD operation that DStreams don’t expose, for example joining each batch with a static RDD of spam data. <code>updateStateByKey</code> keeps state across batches and needs a checkpoint directory. Windows like <code>reduceByKeyAndWindow(..., 30, 10)</code> mean the last 30 seconds computed every 10 seconds, and both numbers must be multiples of the batch interval. Output operations such as <code>saveAsTextFiles</code> and <code>foreachRDD</code> write the results out.</p>
<p>Then the class ends at exactly 5 PM, which he admits surprised him too.</p>
</section>
<section id="My-Checklist">

<h2><a href="#My-Checklist">My Checklist</a></h2>
<p>These are all the tips from the class in one place, so I can come back to this list without rewatching six hours:</p>
<ul>
<li>
Aim for at least as many partitions as cores (1,000 to 10,000 is normal), and <code>coalesce</code> or <code>repartition</code> after a big <code>filter</code>.
</li>
<li>
Never <code>collect</code> something big. Save it or <code>sample</code> it first. Use <code>parallelize</code> only for tests.
</li>
<li>
Cache the cleaned RDD that several actions reuse, not the base RDD you just read, and <code>unpersist</code> it when you’re done.
</li>
<li>
Pick <code>MEMORY_ONLY</code>, then <code>MEMORY_ONLY_SER</code> with Kryo, and only use disk when recomputing is expensive. Use <code>MEMORY_AND_DISK</code> for RDDs right after an expensive shuffle.
</li>
<li>
Use Kryo in production and register your classes.
</li>
<li>
Executor heaps between 8 GB and ~40 GB, and at most ~75% of the machine for Spark, leaving room for HDFS or Cassandra on the same node.
</li>
<li>
Cache right after an expensive shuffle, and pass <code>numPartitions</code> to transformations that shuffle anyway.
</li>
<li>
Oversubscribe task slots 2 to 3 times the free cores, then measure.
</li>
<li>
Put <code>SPARK_LOCAL_DIRS</code> on SSDs, one mount per disk, no RAID.
</li>
<li>
In Standalone, always set <code>spark.cores.max</code> (the total for the app, oversubscribed) and <code>spark.executor.memory</code>, and give executors the memory to match their cores.
</li>
<li>
On YARN, use client mode for shells and cluster mode for jobs you walk away from, and run the history server.
</li>
<li>
Turn on the external shuffle service whenever you use dynamic allocation.
</li>
<li>
Learn which transformations shuffle, read <code>toDebugString</code>, and balance the work between parallel stages.
</li>
<li>
Use <code>mapValues</code> (or <code>preservesPartitioning</code>) when the keys don’t change.
</li>
<li>
When one task in a stage takes far longer than the median, find the hot keys and salt them. Aggregate twice for aggregations, and copy the small side once per salt for joins (or broadcast it if it fits).
</li>
<li>
Use one connection per partition, not per element, when writing to a database.
</li>
<li>
Broadcast small lookup tables instead of joining them, and use accumulators for counters.
</li>
<li>
In PySpark, prefer DataFrames and SQL so the work stays in the JVM. Try PyPy for pure Python code, and raise <code>spark.python.worker.memory</code> if workers run out.
</li>
<li>
In streaming, keep processing time under the batch interval, add receivers to ingest more, and use CMS or G1 instead of the parallel GC.
</li>
<li>
Check the Storage tab for skew, the Environment tab for which settings won, and thread dumps for hot code.
</li>
<li>
Don’t use Spark to answer web requests. Someone asked about it in the Q&amp;A, and his answer was that Spark is a batch engine and isn’t meant for millisecond queries. Run a batch job that fills a database such as Cassandra, and serve the app from that.
</li>
</ul>
</section>
<section role="doc-endnotes">
<hr>
<ol>
<li id="fn1">
<p>Standalone has caught up since. <code>spark.executor.cores</code> now lets one worker run several executors of the same app, so the <code>SPARK_WORKER_INSTANCES</code> trick he shows for big machines isn’t needed, and dynamic allocation works in Standalone with the external shuffle service.<a href="#fnref1" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn2">
<p>These fractions were replaced by unified memory in Spark 1.6. <code>spark.memory.fraction</code> (0.6 by default) is now one pool that caching and execution borrow from each other, and <code>spark.memory.storageFraction</code> (0.5) is the part cached data is protected in. The old settings are gone in Spark 3, but his advice to figure out <em>whose</em> memory ran out still applies. Tachyon has also been renamed Alluxio, and <code>OFF_HEAP</code> now uses Spark’s own off-heap memory.<a href="#fnref2" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn3">
<p>That flag exists on PySpark’s <code>map</code>. In Scala, use <code>mapValues</code>, which keeps the partitioner on its own.<a href="#fnref3" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn4">
<p>Adaptive query execution came in Spark 3.0 and is on by default since 3.2. The skew handling is <code>spark.sql.adaptive.skewJoin.enabled</code>, which is on once AQE is on, and it decides what counts as skewed with <code>skewedPartitionFactor</code> and <code>skewedPartitionThresholdInBytes</code>.<a href="#fnref4" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn5">
<p>The hash-based shuffle was removed in Spark 2.0, so sort-based is the only one now. The off-heap <code>Unsafe</code> work from this benchmark also grew into Project Tungsten, which is why DataFrames manage their own memory today.<a href="#fnref5" role="doc-backlink">↩︎</a></p>
</li>
<li id="fn6">
<p>DStreams still work, but Structured Streaming, built on DataFrames, is what Spark recommends for new streaming jobs.<a href="#fnref6" role="doc-backlink">↩︎</a></p>
</li>
</ol>
</section>
]]>
    </content>
  </entry>
  <entry>
    <title type="text">Apache Spark Configuration Experiences</title>
    <link
      href="https://hattajr.github.io/2021/08/05/spark-configuration-experiences.html"
      rel="alternate" type="text/html"
      title="Apache Spark Configuration Experiences" />
    <published>2021-08-05T00:00:00+00:00</published>
    <updated>2021-08-05T00:00:00+00:00</updated>
    <id>https://hattajr.github.io/2021/08/05/spark-configuration-experiences</id>
    <author>
      <name>Muhammad Hatta</name>
    </author>
    <summary type="html">
      <![CDATA[Some important spark configuration]]>
    </summary>
    <content type="html"
      xml:base="https://hattajr.github.io/2021/08/05/spark-configuration-experiences.html">
      <![CDATA[
<header>
  <h1>Apache Spark Configuration Experiences</h1>
  <time class="meta" datetime="2021-08-05">Aug 5, 2021</time>
</header>
<ul>
<li>
<p>Some important spark configuration</p>

<figure class="code-block">


<pre><code><span class="line">--conf spark.driver.memory=90g \</span>
<span class="line">--conf spark.sql.files.maxPartitionBytes=<span class="hl-number">4194304</span> \</span>
<span class="line">--conf spark.eventLog.enabled=true \</span>
<span class="line">--conf spark.eventLog.<span class="hl-built_in">dir</span>=/opt/airflow/dags/spark-events</span></code></pre>

</figure>
</li>
<li>
<p>Partition configuration
- Input partition
- control the <strong><strong>input partition</strong></strong> by using <code>spark.sql.files.maxPartitionBytes</code>
- default is 128mb, but if you will have explode operation, consider to decrease this value
- control the <strong><strong>shuffle partition</strong></strong> by using <code>spark.sql.shuffle.partitions</code>
- default is 200 partition, but if you will have explode operation, consider to decrease this value
- control the <strong><strong>output partition</strong></strong> by using <code>.option("maxRecordsPerFile", "x")</code></p>
</li>
<li>
<p>Estimate the number of partition</p>

<figure class="code-block">


<pre><code><span class="line"><span class="hl-keyword">import</span> numpy <span class="hl-keyword">as</span> np</span>
<span class="line"></span>
<span class="line"></span>
<span class="line">sep = <span class="hl-string">&quot;*&quot;</span> * <span class="hl-number">70</span></span>
<span class="line"><span class="hl-built_in">print</span>(<span class="hl-string">f&quot;\n\n<span class="hl-subst">{sep}</span>&quot;</span>)</span>
<span class="line"></span>
<span class="line"><span class="hl-string">&quot;&quot;&quot;</span></span>
<span class="line"><span class="hl-string">IF THE SHUFLLE PARTITION IS NOT CHANGE</span></span>
<span class="line"><span class="hl-string">IN THE SPARK UI, PLEASE CHECK THE FOLLOWING:</span></span>
<span class="line"><span class="hl-string">- REMOVE THE TABLE IN MINIO</span></span>
<span class="line"><span class="hl-string">  UNDER THE SAME NAME OF THE RUNNING SPARK APP</span></span>
<span class="line"><span class="hl-string">&quot;&quot;&quot;</span></span>
<span class="line">CONS_SHUFFLE_RATIO_ROWS = <span class="hl-number">1</span>/<span class="hl-number">21</span></span>
<span class="line">CONST_MB_TO_BYTES = <span class="hl-number">1024</span> * <span class="hl-number">1024</span></span>
<span class="line"></span>
<span class="line">total_num_rows = <span class="hl-number">5932823</span> &lt;- you only need this <span class="hl-keyword">as</span> <span class="hl-built_in">input</span></span>
<span class="line"></span>
<span class="line">seed_no = <span class="hl-number">123</span></span>
<span class="line">num_cores = <span class="hl-built_in">int</span>(<span class="hl-number">32</span>)</span>
<span class="line">total_available_memory_gb= <span class="hl-number">125</span></span>
<span class="line">reserved_memory_gb = <span class="hl-number">35</span></span>
<span class="line"></span>
<span class="line">target_input_max_bytes_per_file_mb = <span class="hl-number">4</span></span>
<span class="line">target_input_max_bytes_per_file_bytes = (</span>
<span class="line">    target_input_max_bytes_per_file_mb</span>
<span class="line">    * CONST_MB_TO_BYTES</span>
<span class="line">)</span>
<span class="line"></span>
<span class="line">max_records_per_file = <span class="hl-number">1_000</span></span>
<span class="line"></span>
<span class="line">num_shuffle_partitions = <span class="hl-built_in">int</span>(</span>
<span class="line">    np.ceil(total_num_rows * CONS_SHUFFLE_RATIO_ROWS)</span>
<span class="line">)</span>
<span class="line"></span>
<span class="line"><span class="hl-built_in">print</span>(<span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;SEED NO&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : <span class="hl-subst">{seed_no}</span>&quot;</span>)</span>
<span class="line"><span class="hl-built_in">print</span>(<span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;NUM CORES&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : <span class="hl-subst">{num_cores}</span>&quot;</span>)</span>
<span class="line"><span class="hl-built_in">print</span>(</span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;TOTAL MEMORY DRIVER&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{total_available_memory_gb-reserved_memory_gb}</span> gb&quot;</span></span>
<span class="line">)</span>
<span class="line"><span class="hl-built_in">print</span>(</span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;MEMORY PER CORE&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{(total_available_memory_gb - reserved_memory_gb)<span class="hl-string">&quot;</span></span></span></span>
<span class="line"><span class="hl-string"><span class="hl-subst"><span class="hl-string">    f&quot;</span>/num_cores}</span> gb&quot;</span></span>
<span class="line">)</span>
<span class="line"></span>
<span class="line"><span class="hl-built_in">print</span>(</span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;MAX INPUT SIZE&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{target_input_max_bytes_per_file_mb}</span> mb / &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{target_input_max_bytes_per_file_bytes}</span> bytes&quot;</span></span>
<span class="line">)</span>
<span class="line"><span class="hl-built_in">print</span>(</span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;SHUFFLE PARTITION&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{num_shuffle_partitions}</span> partitions&quot;</span></span>
<span class="line">)</span>
<span class="line"><span class="hl-built_in">print</span>(</span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{<span class="hl-string">&#x27;MAX RECORDS PER FILE&#x27;</span>:&lt;<span class="hl-number">30</span>}</span> : &quot;</span></span>
<span class="line">    <span class="hl-string">f&quot;<span class="hl-subst">{max_records_per_file}</span> records&quot;</span></span>
<span class="line">)</span>
<span class="line"><span class="hl-built_in">print</span>(<span class="hl-string">f&quot;<span class="hl-subst">{sep}</span>\n\n&quot;</span>)</span></code></pre>

</figure>
</li>
</ul>
]]>
    </content>
  </entry>
</feed>
