<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Raghuveer's blog]]></title><description><![CDATA[Raghuveer's blog]]></description><link>https://raghuveer.me</link><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 22:04:28 GMT</lastBuildDate><atom:link href="https://raghuveer.me/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Combine Dataform with Typescript for Improved Workflows]]></title><description><![CDATA[“But how do you ensure every BigQuery table you create has the same metadata in Dataform?”
Yes, I thought to myself, how do I ensure that? This was a question posed recently to me and I realized I don’t quite have an answer. And I really didn’t want ...]]></description><link>https://raghuveer.me/combine-dataform-with-typescript-for-improved-workflows</link><guid isPermaLink="true">https://raghuveer.me/combine-dataform-with-typescript-for-improved-workflows</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[google cloud]]></category><category><![CDATA[dataform]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Wed, 29 Jan 2025 20:00:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1737852036878/89efa443-1647-416a-abfb-a97937abed03.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>“But how do you ensure every BigQuery table you create has the same metadata in Dataform?”</p>
<p>Yes, I thought to myself, <em>how do I</em> ensure that? This was a question posed recently to me and I realized I don’t quite have an answer. And I really didn’t want to use Terraform as the solution. We could use Javascript with Dataform and use objects that are passed around. That would certainly help.</p>
<p>But Typescript is just oh so much nicer. Is there a way to use it?</p>
<p>On first thought, it seemed quite simple. Typescript compiles to Javascript. Dataform has a <code>package.json</code> so <em>clearly</em> it supports node modules. I can probably just install anything I want, and then simply use the compiled Javascript files for my workflow. Time to wrap this one up no? Not so fast.</p>
<p>Turns out there are a host of problems and I will go through some the things I bumped into in this little experiment. But if you want to skip all of that and just get the code, here’s a preview into what the final thing looks like.</p>
<h1 id="heading-a-peek-into-the-final-result">A peek into the final result</h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737751448223/e17529aa-ff2b-4b45-89a4-70f4c8d85080.png" alt class="image--center mx-auto" /></p>
<p>As you can see, there are typescript files under <code>src</code> folder, javascript under the <code>build</code> folder and finally the dataform files under <code>definitions</code>. This allows us to clean typed definitions, chain functions with autocomplete, get type hints, the whole deal (well, almost as you will soon see).</p>
<p>The repository with code is here: <a target="_blank" href="https://github.com/raghuveer-s/combine-dataform-with-typescript/tree/main">https://github.com/raghuveer-s/combine-dataform-with-typescript/tree/main</a></p>
<p>Now let’s breakdown this journey.</p>
<h1 id="heading-problems-and-solutions">Problems and solutions</h1>
<h2 id="heading-missing-type-definitions">Missing type definitions</h2>
<p>The very first thing you might look for is type definitions when working with a greenfield Typescript project. But unfortunately, there is no convenient <code>@types/dataform</code> provided by Google in npm. If you read the Dataform docs on using Javascript or look at the dataform core reference (<a target="_blank" href="https://cloud.google.com/dataform/docs/reference/dataform-core-reference">https://cloud.google.com/dataform/docs/reference/dataform-core-reference</a>), its clear we can use types in the Typescript code. It also does exist to some degree in the source code in the core package (<a target="_blank" href="https://github.com/dataform-co/dataform/tree/main/core">https://github.com/dataform-co/dataform/tree/main/core</a>) and the protobuf files.</p>
<p>First problem solved, we can use a mix of the documentation and the source to extract the type definitions we need: <a target="_blank" href="https://github.com/raghuveer-s/combine-dataform-with-typescript/blob/main/src/global.d.ts">https://github.com/raghuveer-s/combine-dataform-with-typescript/blob/main/src/global.d.ts</a></p>
<h2 id="heading-node-modules-are-supported-but-not-really">Node modules are supported.. but not really?</h2>
<p>Dataform documentation mentions that you can install packages through <code>package.json</code> (<a target="_blank" href="https://cloud.google.com/dataform/docs/install-package#install-package">https://cloud.google.com/dataform/docs/install-package#install-package</a>). This sounds great. Node modules embedded along with ETL transformations? Sign up me up so I says!</p>
<p>But unfortunately, this was yet another road bump. YMMV greatly here.</p>
<p>When I was experimented with installing even the simplest of modules, I ran into compilation issues. Here’s the usecase I used (building off the brewery example as always, refer here: <a target="_blank" href="https://raghuveer.me/a-practical-introduction-to-google-cloud-dataform">https://raghuveer.me/a-practical-introduction-to-google-cloud-dataform</a>):</p>
<ul>
<li><p>The daily location sales has a column which is the total sales in INR (Indian Rupees).</p>
</li>
<li><p>So, how about I enrich this by getting the latest exchange rate and have the total sales in USD (US Dollar)?</p>
</li>
</ul>
<p>A little contrived, but I think it makes sense in the context of sales reports.</p>
<p>To do this, I downloaded a really small module called <code>currency-exchange-rates</code> (<a target="_blank" href="https://www.npmjs.com/package/currencies-exchange-rates">https://www.npmjs.com/package/currencies-exchange-rates</a>) and attempted using it, but even before I could check if the module’s currency conversion still works, I kept running into compilation issues. A couple of things I discovered while debugging:</p>
<ul>
<li><p>Dataform only uses CommonJS.</p>
</li>
<li><p>Overcoming this hurdle through bundlers is still a no go, as there seems to be a strict subset of modules that are allowed in the end.</p>
</li>
</ul>
<h3 id="heading-going-down-the-rabbit-hole">Going down the rabbit hole</h3>
<p>What struck me as odd is I tried it with the module mentioned in the documentation (<code>postoffice</code>, for which the source doesn’t seem to exist on Github) but even then it resulted in compilation error. Iirc, the error was a message which said it failed to find node:url. This was interesting, if this is the inbuilt <code>url</code> package and Dataform complains with compilation errors, then I have to ask if dependencies are severely restricted in some way. I half-confirmed this hypothesis by installing <code>is-buffer</code> library which has no dependencies and no compilation errors surfaced.</p>
<p>Needing more information, I decided to check things locally using <code>dataform compile</code> first. I tried bundling everything into bundle.js file wondering if that might help, but I kept running into <code>VMError</code>.</p>
<p>Browsing the source, what I can gather is that Dataform uses vm2 (<a target="_blank" href="https://github.com/patriksimek/vm2">https://github.com/patriksimek/vm2</a>) as a way to execute untrusted Javascript code. This means code and modules are executed in an isolated context, and the support for even for built in modules maybe restricted, let alone support for any module from npm. This constraint also explains the need to use CommonJS because vm2 is built on top of node vm (<a target="_blank" href="https://nodejs.org/api/vm.html">https://nodejs.org/api/vm.html</a>).</p>
<p>So it would seem the only way to avoid the problem is.. by not using most modules from npm in the core code. Which is where I stand right now. All the modules I’ve used in the Typescript repo so far are for testing, lint, building etc.</p>
<p><strong>Honestly, this one’s a bummer.</strong> I do hope I’m in the wrong here and I just misunderstood the documentation somehow. Or if someone can suggest a workaround, that would be awesome. Technically I guess you could this by stitching together a pipeline with Workflows, Cloud Functions and so on.</p>
<p>I really hope Dataform can do this in the future. In my opinion, there is a strong case for working with node modules and enriching workflows directly in Dataform. But for now, we soldier on.</p>
<h2 id="heading-global-scope">Global scope</h2>
<p><code>includes/</code> folder can have Javascript files with constants and utility functions (<a target="_blank" href="https://cloud.google.com/dataform/docs/reuse-code-includes">https://cloud.google.com/dataform/docs/reuse-code-includes</a>), but using them with <code>module.exports</code> as mentioned in the docs makes them available in the global scope.</p>
<p>In other words: Using them from other files does not strictly need a <code>require()</code>. Referencing the file name allows you to use the exported constant or function. You can see an example of this here: <a target="_blank" href="https://github.com/GoogleCloudPlatform/bigquery-utils/blob/09082672f8a35037f79a54d166cf17fca7792c6d/dataform/examples/dataform_assertion_unit_test/definitions/tests/test_date_assertions.js">https://github.com/GoogleCloudPlatform/bigquery-utils/blob/09082672f8a35037f79a54d166cf17fca7792c6d/dataform/examples/dataform_assertion_unit_test/definitions/tests/test_date_assertions.js</a></p>
<p>I find this a bit confusing. I would rather use the <code>require()</code> instead to reference modules that have Javascript code. Turns out this is works just as a regular <code>require()</code> where we specify the path of the module.</p>
<p>This is both interesting and important to keep in mind.</p>
<h2 id="heading-folder-structure">Folder structure</h2>
<p>Dataform has a strict folder structure requirement. It <em>needs</em> <code>definitions/</code>, and after that <code>includes/</code> for any utility functions.</p>
<p>And now this one’s one me, but I don’t like my typescript code’s folder structure being coerced like this, I want to have my tables in a <code>tables</code> folder, my ETL in a <code>transformations</code> folder, my tests in a <code>tests</code> folder and so on. Which means I need to map my folder structure to what Dataform wants and modify import paths if necessary to have it respect Dataform’s requirements in the final <code>.js</code> files.</p>
<p>Any good build tool and some scripts should be able take care of this one.</p>
<h2 id="heading-path-referencing-and-manipulation">Path referencing and manipulation</h2>
<p>Now we get to the glue that makes all of this work.</p>
<p>To smoothly interoperate between Typescript land and compiled Javascript, we can use <code>tsconfig.json</code>. Depending on what you are trying to do, there are a few options available:</p>
<ul>
<li><p><code>paths</code> config in <code>tsconfig.json</code> to remap imports to the correct location. I used this to remap all paths with a prefix of <code>@includes/</code> to resolve to modules in the <code>includes/</code> directory.</p>
</li>
<li><p>Use <code>declare</code> to declare the variable or function to the Typescript compiler. This is useful, for example, to make functions accessible globally with any imports. It can be used when path manipulation is tricky.</p>
</li>
<li><p>And lastly, you have <code>typeRoots</code> which is useful if you want to add more organization to your code or you have more than one type definition file. For example, you could create a <code>@types</code> folder for your type definitions and add <code>typeRoots</code> in <code>tsconfig.json</code> with the folders that are interesting to you.</p>
</li>
</ul>
<p>I used <code>paths</code> configuration to help with the module resolution. You could use a mix of the above to varying effect.</p>
<p>The build tool I used is rollup (<a target="_blank" href="https://rollupjs.org/guide/en/">https://rollupjs.org/guide/en/</a>) with the <code>replace()</code> plugin to transform the <code>@includes</code> paths into <code>./includes</code> and wrote a tiny script to move the files I want from the build output into a structure Dataform finds acceptable. Depending on how you structure your project, this can technically mean, that the files have (or may have) incorrect paths in the build directory. For the final code in this repository, this is infact what happens. It can be corrected, but my reasoning was that I’m never going to use the artifacts in the <code>build/</code> directory apart from being a temporary location (and I got lazy) to copy over from into Dataform’s folder structure so I cheated a little and let it be.</p>
<p>This is the most important section in this experiment. Depending on how you organize your project, your <code>tsconfig.json</code> and <code>rollup.config.json</code> will be the files that need tweaking.</p>
<h2 id="heading-building-the-code">Building the code</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737831187961/67a8da85-4b08-440e-977a-d732eb9edc6d.png" alt class="image--center mx-auto" /></p>
<p>With most of the heavy lifting being done with <code>tsconfig.json</code>, <code>rollup.config.json</code>, the code can be built as a regular Typescript project.</p>
<p>You could also chain <code>dataform compile</code> to this and check locally if the compilation is valid! This way a lot of the work is shift left, we spend less time going online and checking if things work in the Dataform Workspace code editor.</p>
<p>Now coming to the original question of how do you ensure BigQuery tables created have the same configuration. As a first step, we can create a “BigQuery Incremental Table Config” and ensure that it has the properties that you want (partition, clustering spec etc etc) and use this in the <code>publish()</code> method.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737841399538/9a93deca-7d0b-4dad-8faf-0b6a0ac8de18.png" alt class="image--center mx-auto" /></p>
<p>Next, we could even think of enforcing rules for <code>publish()</code> methods, <em>for</em> “daily sales data”, to get called <em>only</em> with this incremental table config. I certainly think this can tweaked further to harden the transformation logic and make it resilient to regressions or errors.</p>
<p>The key take away is that we now have programmatic way of checking what we are doing with our data transforms.</p>
<h1 id="heading-putting-it-all-together">Putting it all together</h1>
<p>The source code as mentioned is here: <a target="_blank" href="https://github.com/raghuveer-s/combine-dataform-with-typescript/tree/main">https://github.com/raghuveer-s/combine-dataform-with-typescript/tree/main</a>.</p>
<p>The end result is we have our Dataform workflows in full typed code with unit tests, which in my opinion does make it much more maintainable. It’s a proof of concept and is a bit rough around the edges, but I intend to use it as a base and experiment with it in future projects. Let me know if it was useful!</p>
]]></content:encoded></item><item><title><![CDATA[Writing data quality tests in Dataform]]></title><description><![CDATA[Reliable, accurate data is the foundation of data-driven decision-making. Poor data quality can lead to incorrect insights, a lack of trust in data and data products. It really is quite an obvious statement to make, but what’s not obvious is how exac...]]></description><link>https://raghuveer.me/writing-data-quality-tests-in-dataform</link><guid isPermaLink="true">https://raghuveer.me/writing-data-quality-tests-in-dataform</guid><category><![CDATA[dataform]]></category><category><![CDATA[bigquery]]></category><category><![CDATA[data-quality]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sun, 24 Nov 2024 08:00:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/KgLtFCgfC28/upload/740dc0f215b54636bfb56de246903c96.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Reliable, accurate data is the foundation of data-driven decision-making. Poor data quality can lead to incorrect insights, a lack of trust in data and data products. It really is quite an obvious statement to make, but what’s not obvious is how exactly to go about getting this “reliable, accurate” data.</p>
<p>Dataform tries to tackle this problem through <strong>"Assertions"</strong>. And since it is Dataform, you can validate data with SQL or Javascript.</p>
<p>Jump to the code here: <a target="_blank" href="https://github.com/raghuveer-s/brewery-dataform/tree/assertions-data-quality/definitions">https://github.com/raghuveer-s/brewery-dataform/tree/assertions-data-quality/definitions</a></p>
<hr />
<h3 id="heading-wait-assertions-you-mean-like-unit-tests"><strong>Wait. Assertions? You mean like unit tests?</strong></h3>
<p>Kinda, sorta. Unit tests in SQL do exist, but that is different from tests for Data Quality.</p>
<p>For SQL unit tests, we fix the inputs, apply the transformation, and set expectations on what the output should look like. For the most part, it tests the actual transformation. Which is very similar in principle to unit tests in code, where we want to test the behavior of the function.</p>
<p>However, data quality tests set expectations about the nature of the data itself. For example, let’s say in a table called <code>user_first_visit</code>, you’d expect every row to be a user and none of those users are repeated, after all you can have more than one first visit. You’d also expect user id never to be null. We’re not talking about the output of the data transformation, but rather the nature of the output of the data transformation. In general, this probably will be closely tied in with the expectations that the consumer of the data has.</p>
<p>A key point where the difference between SQL unit tests and data quality tests comes into focus is that, data quality tests can break without you ever changing data pipelines. As in, the tests are not strictly Hermetic. For example, let’s say you have a validation on a user’s mobile phone column which checks for number of digits to ensure a valid mobile number. If your company expands into different geographies, it may happen that this particular validation breaks.</p>
<p>In short, the idea behind good data quality is that we capture the “correctness” of the data itself.</p>
<hr />
<h2 id="heading-assertions-in-dataform"><strong>Assertions in Dataform</strong></h2>
<p>Once again we’ll use the brewery data seen in the <a target="_blank" href="https://raghuveer.me/a-practical-introduction-to-google-cloud-dataform">Introduction to Dataform</a> post. In the brewery data, we have the daily sales data job which runs every day. The table has just 3 columns : location, daily sales and date. If I imagine myself as a brewery owner for a moment, what I would expect of my daily sales data is:</p>
<ul>
<li><p>The daily sales data must be generated for every location, which makes location a unique value in the context of that day.</p>
</li>
<li><p>Date must make sense. I.e, if I was looking at yesterday’s daily sales as a report, it must only have data with yesterday’s date.</p>
</li>
<li><p>Daily sales of each location is &gt; 0.</p>
</li>
</ul>
<p>This is the “business expectations” part of data quality. We talk about the first two in the inbuilt assertions section.</p>
<p>The last one is interesting. Sales of a location “<em>must be greater than 0</em>”? What if there were no sales at that location? Then technically it’s not a “<em>data</em>” issue right? Well.. maybe. Maybe the data pipeline is indeed broken, but maybe something happened at the data source, maybe the internet was down, maybe this maybe that. The only thing we know for a fact is: It’s <em>weird</em> if sales is 0 all of a sudden. Strictly speaking, this falls under data anomaly detection. And the cool thing about Dataform is that it integrates very well with BigQuery. And BigQuery has anomaly detection out of the box (<a target="_blank" href="https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-detect-anomalies">https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-detect-anomalies</a>). We’ll touch upon this in the manual assertions section.</p>
<h3 id="heading-inbuilt-assertions">Inbuilt assertions</h3>
<p>Data quality checks on the generated data is very useful, the earlier we know about quality issues, the better. The inbuilt assertions can be set in the <code>config</code> block, which means the same SQLX you use for <code>table</code> / <code>incremental</code> / <code>view</code> workflow objects just need a bit of tweaking.</p>
<p>Out of the box, Dataform offers:</p>
<ul>
<li><p>Null checks</p>
</li>
<li><p>Uniqueness checks</p>
</li>
<li><p>Custom row level conditions</p>
</li>
</ul>
<p>For us this, this looks like:</p>
<pre><code class="lang-sql">config {
  type: "incremental",
  // .. the other configurations
  // Note: Remember the partitions filtering caveat mentioned below
  assertions: {
    nonNull: ["Location"],
    uniqueKey: ["Location"],
    rowConditions: [
      'd &lt; TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), DAY)'
    ]
  }
}

<span class="hljs-keyword">SELECT</span>
  Location <span class="hljs-keyword">as</span> location, 
  TIMESTAMP_TRUNC(Brew_Date, <span class="hljs-keyword">DAY</span>) d, 
  <span class="hljs-keyword">SUM</span>(Total_Sales) <span class="hljs-keyword">AS</span> daily_location_sales
<span class="hljs-keyword">FROM</span>
  ${<span class="hljs-keyword">ref</span>(<span class="hljs-string">"brewery_partitioned_clustered"</span>)}
<span class="hljs-keyword">WHERE</span>
  TIMESTAMP_TRUNC(Brew_Date, <span class="hljs-keyword">DAY</span>) &gt;= timestamp_checkpoint
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span>
  location, d

pre_operations {
  <span class="hljs-keyword">DECLARE</span> timestamp_checkpoint <span class="hljs-keyword">DEFAULT</span> (
    ${<span class="hljs-keyword">when</span>(incremental(),
    <span class="hljs-string">`SELECT MAX(d) FROM ${self()} WHERE d is not null`</span>,
    <span class="hljs-string">`SELECT TIMESTAMP("2023-01-01")`</span>)}
  )
}
</code></pre>
<p><strong>Note:</strong> If partition filters are mandated using <code>requirePartitionFilter</code> in the config block, the inbuilt assertions <strong>will not work</strong>. This is because internally, Dataform uses Views to implement assertions. And since a View is basically just a query, it requires the partition filter to be specified, and Dataform has no way to know ahead of time which partition you want for the View.</p>
<p>For example, the uniqueness constraint above gets converted into this query by Dataform:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1732383766724/57bf60ec-dda3-433e-8792-db7910a3e5a5.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-manual-assertions">Manual assertions</h3>
<p>This is my preferred way of writing assertions because I like the idea of separating my code which does the assertions and the code that does the data transformations. In this style, assertions are just another Action in Dataform. This means you can utilize the full power of BigQuery SQL. And since it’s just another Action, you can have independent tags for assertions, dependencies become much more readable in my opinion, and even specify assertions in a different release configuration with its own schedule if needed.</p>
<p>Since we’re looking at anomaly detection, we will use BigQuery’s machine learning capabilities. Let’s keep it simple and use k-means.</p>
<p>Let’s create the model in BigQuery first.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">OR</span> <span class="hljs-keyword">REPLACE</span> 
<span class="hljs-keyword">MODEL</span> <span class="hljs-string">`website-code-395711.brewery.sales_anomaly`</span>
OPTIONS(
  MODEL_TYPE=<span class="hljs-string">'kmeans'</span>,
  KMEANS_INIT_METHOD=<span class="hljs-string">'kmeans++'</span>,
  NUM_CLUSTERS=<span class="hljs-number">10</span>
)
<span class="hljs-keyword">AS</span>
<span class="hljs-keyword">SELECT</span>
  location, daily_location_sales
<span class="hljs-keyword">FROM</span> 
  <span class="hljs-string">`brewery.daily_area_sales`</span>
<span class="hljs-keyword">WHERE</span> 
  d <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>;
</code></pre>
<p>Then, we need the anomalous data. For this, I just manually inserted daily sales data with 0 value for the location “Koramangala”. And for those of you who know the scene in Koramangala, Bengaluru, you can most definitely agree that a brewery having beer sales of 0 is definitely weird 😁</p>
<p>Next, run the anomaly detection as an assertion. Create a new folder called “assertions” under “definitions” folder, and then the <code>sales_anomaly.sqlx</code>.</p>
<pre><code class="lang-sql">config {
  type: "assertion",
  tags: ["daily", "anomaly_detection"]
}

<span class="hljs-keyword">SELECT</span>
  *
<span class="hljs-keyword">FROM</span>
  ML.DETECT_ANOMALIES(
    <span class="hljs-keyword">MODEL</span> <span class="hljs-string">`brewery.sales_anomaly`</span>,
    <span class="hljs-keyword">STRUCT</span>(<span class="hljs-number">0.01</span> <span class="hljs-keyword">AS</span> contamination),
    (<span class="hljs-keyword">SELECT</span> location, daily_location_sales <span class="hljs-keyword">FROM</span> <span class="hljs-string">`brewery.daily_area_sales`</span> <span class="hljs-keyword">WHERE</span> d &gt;= <span class="hljs-built_in">TIMESTAMP</span>(<span class="hljs-string">'2024-01-01'</span>))
  )
<span class="hljs-keyword">WHERE</span>
  is_anomaly <span class="hljs-keyword">IS</span> <span class="hljs-literal">TRUE</span>
</code></pre>
<p>If done right, you should see something this.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1732376859277/b787e0f8-91c8-477d-b51b-659d4c52e9ca.png" alt class="image--center mx-auto" /></p>
<p>This is great, we can use all of BigQuery’s cool stuff directly in Dataform. But still, something is not <em>quite</em> right. Assertions are “binary” in some sense, that is they can say “all good” or scream “Alert!”, but anomalies fall somewhere in the middle which says “Hey, something’s odd.”. Unfortunately, this type of alert levels is not something doable out of the box in Dataform and needs more effort.</p>
<h3 id="heading-writing-assertions-in-javascript">Writing Assertions in Javascript</h3>
<p>Since Javascript is a first class citizen in Dataform, you can use it write assertions as well. It becomes extremely easy to create test assertions by applying programming patterns, for example, if we have the same test assertion to apply for different tables, we can just do so using for loops. You can also bring the benefits of having node modules into the mix. For example, I can imagine using json schema validators if you are storing event data in your tables. We’ll cover using Javascript in Dataform with some examples in a future article.</p>
<hr />
<h2 id="heading-a-word-on-data-quality">A word on Data Quality</h2>
<p>So far, we’ve described the why and how of Data Quality tests but to a lesser extent, the what to test for. A brief overview of the so-called “<em>six pillars of data quality</em>” should give a bit more direction in that regard:</p>
<ul>
<li><p><strong>Accuracy</strong>: The degree to which data about the real world object or event is captured. Eg: Filling out a tourist visa application and you put a space accidentally in your first name, leave the middle name blank, and fill the last name correctly. It is easy to imagine that this may cause problems as you try to explain yourself at the immigration counter. Closely related to precision. Eg: Event timestamps till millisecond granularity.</p>
</li>
<li><p><strong>Completeness</strong>: How “complete” is the data being stored. Say for user data, do we have everything that adequately describes the user for our needs? Eg: A name can be first name, last name. But you could also capture middle name, birth name (Will your system use it though?).</p>
</li>
<li><p><strong>Consistency</strong>: Multiple data stores must not differ in the data stored. Eg: Same userId must not have different birth dates in two different systems.</p>
</li>
<li><p><strong>Timeliness</strong>: Freshness of the data. Eg: Must not store events timestamped in the future, must not have “stale” data (definition of stale comes from business logic).</p>
</li>
<li><p><strong>Uniqueness</strong>: Unique constraints in the world must be respected in the data as well. For example, unique email ids. There are some edge cases here though. For example, Mobile numbers are unique, but they can be reassigned to a different person if the original holder decides to cancel their mobile subscription.</p>
</li>
<li><p><strong>Validity</strong>: Semantic or syntactic checks. Eg: Email must have “@” in it.</p>
</li>
</ul>
<hr />
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>We’ll close off with a quick overview of the pros and cons.</p>
<p><strong>Pros:</strong></p>
<ul>
<li><p>If you’re using BigQuery, data quality assurance becomes extremely easy.</p>
</li>
<li><p>Assertions are written in SQL or Javascript. SQL is the lingua franca of the data domain, Javascript brings node modules with it should you need it. Both are widely used and powerful, making it a strong choice for writing data quality checks.</p>
</li>
<li><p>Assertion failures are just like workflow execution failures. Which means if you have observability built on Google Cloud Logging, you can easily reuse that for alerts on data quality.</p>
</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li><p>Rows that break the assertions are accessible through a view. But since this is created on every run of the assertions, if the next assertion is run before you get the chance to view the existing assertion failures, the failing rows may be lost.</p>
</li>
<li><p>While we can alert on data quality failures, we cannot configure different alerting levels. I’ve often found that some data quality checks are more important than others, having a way to configure this does not exist out of the box.</p>
</li>
</ul>
<p>Overall, Dataform assertions offer a simple and flexible way to get started on ensuring the quality of your data in BigQuery. However, it’s simplicity does have some tradeoffs. It’s a great tool in the data quality toolbox, but it does have its limitations compared to more powerful tools out there.</p>
]]></content:encoded></item><item><title><![CDATA[A Practical Introduction to Google Cloud Dataform]]></title><description><![CDATA[Dataform is a tool that creates data pipelines using SQL. If you’re familiar with Dbt, Dataform is probably best understood as Dbt-esque tool that integrates really well with BigQuery and other Google Cloud products. In a short amount of time, it’s q...]]></description><link>https://raghuveer.me/a-practical-introduction-to-google-cloud-dataform</link><guid isPermaLink="true">https://raghuveer.me/a-practical-introduction-to-google-cloud-dataform</guid><category><![CDATA[dataform]]></category><category><![CDATA[google cloud]]></category><category><![CDATA[bigquery]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Mon, 04 Nov 2024 10:00:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/L4gN0aeaPY4/upload/0947f3e634651c9cda3e672ebd202174.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Dataform is a tool that creates data pipelines using SQL. If you’re familiar with Dbt, Dataform is probably best understood as Dbt-esque tool that integrates really well with BigQuery and other Google Cloud products. In a short amount of time, it’s quickly grown to become one of my favorite tools in the data space. I cannot overstate how much it simplifies creating reliable and scalable pipelines. In this post, we’ll quickly go over its components and illustrate with a working example.</p>
<p><strong>Note:</strong> We use Dataform v3 which changes things a little from the v2 version.</p>
<hr />
<h2 id="heading-architecture">Architecture</h2>
<p>Let’s start with some key pieces that we work with in Dataform.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730672685273/8e1fc4c2-cc07-4246-bf88-bc4b964f59cf.png" alt class="image--center mx-auto" /></p>
<p>Going on by one:</p>
<ul>
<li><p>Dataform Repository: The place to store the pipeline code. Every Dataform project needs to have a <em>Repository</em> as a top level object. We can connect the Dataform Repository to a Git Repository.</p>
</li>
<li><p>Dataform Workspace: Online editor where we can edit the code.</p>
</li>
<li><p>SQLX: Dataform has extended SQL a little to make it easier to define pipelines, it’s dependencies and so on. Eventually it all gets compiled into SQL.</p>
</li>
<li><p>Javascript: We can use Javascript along with SQLX. Even node modules can be installed, which is pretty cool!</p>
</li>
<li><p>Action: The smallest executable unit. Most of the time it’s SQLX code.</p>
</li>
<li><p>Workflow: A collection of Actions. This is the pipeline, essentially. When run, Dataform calls it “<em>creating a workflow invocation</em>”.</p>
</li>
<li><p>Release configuration: The result of compiling the SQLX files in the codebase. The compilation can be parameterized by passing in variables.</p>
</li>
<li><p>Workflow configuration: A workflow configuration is the set of Actions chosen to be executed.</p>
</li>
<li><p>BigQuery: Provides source and destination tables.</p>
</li>
</ul>
<hr />
<h2 id="heading-creating-the-pipeline-step-by-step">Creating the pipeline step-by-step</h2>
<h3 id="heading-a-word-on-permissions-before-we-begin">A word on permissions before we begin</h3>
<p>There are two principals who needs permissions:</p>
<ol>
<li><p>Dataform service account (which is created automatically when repository is created in the next step).</p>
</li>
<li><p>The user administering dataform settings.</p>
</li>
</ol>
<p>Refer to these documents for more:</p>
<ol>
<li><p><a target="_blank" href="https://cloud.google.com/dataform/docs/required-access">https://cloud.google.com/dataform/docs/required-access</a></p>
</li>
<li><p><a target="_blank" href="https://cloud.google.com/dataform/docs/connect-repository">https://cloud.google.com/dataform/docs/connect-repository</a></p>
</li>
</ol>
<p>But if you don’t want to read the docs and are in a position to grant yourself additional roles (lucky you), then add <code>roles/bigquery.dataEditor</code>, <code>roles/bigquery.user</code>, <code>roles/secretmanager.secretAccessor</code> to the dataform service account, <code>roles/dataform.admin</code> to the user and keep cracking on.</p>
<h3 id="heading-create-and-connect-the-repository">Create and connect the repository</h3>
<p>A “Dataform repository” is a top level concept. It hosts the code that makes up the pipeline. It can be connected to a Git Repository.</p>
<p>To connect to a remote Git repo, just click on the “Connect with Git” and follow the UI. You will need to create a Secret and store it as well which allows you to connect securely to the Git repository. Both of these steps need adequate permissions.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730240802033/ca5ebcd7-8b39-48e1-a900-f55214a1dcc9.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730240829709/8d98a11d-20dd-4f33-96e5-167f7395bcb8.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-optional-create-a-development-workspace">Optional: Create a development workspace</h3>
<p>The docs define a Workspace as “<em>an editable copy of your repository</em>”. It’s more easily understood as an online editor UI for code. It allows users to work independently directly on the UI online, commit their changes to git, execute workflows and so on. I’ve found it useful for quick prototyping and visualizing the pipeline DAG.</p>
<h3 id="heading-local-development-environment">Local development environment</h3>
<p>For Dataform, I use VSCode. There’s even a Dataform extension that comes in useful for a good development environment.</p>
<p>To get started, follow these steps:</p>
<ol>
<li><p>Search and install the Dataform extension for VSCode. This provides syntax highlighting and on the fly compilation which can detect errors early in the development cycle.</p>
</li>
<li><p>Create a local folder for the code and step into it.</p>
</li>
<li><p>Install the <code>dataform</code> package through node: <code>npm i -g @dataform/core</code>.</p>
</li>
<li><p>Execute <code>dataform init . &lt;gcp_project_id&gt; &lt;bigquery_dataset_location&gt;</code>. Use <code>dataform help</code> to see what other options there are.</p>
</li>
</ol>
<p>My dataform core version is 3.0.7 (the release notes can be tracked here: <a target="_blank" href="https://cloud.google.com/dataform/docs/release-notes">https://cloud.google.com/dataform/docs/release-notes</a>) and as of this version, it creates three things:</p>
<ol>
<li><p><code>definitions</code> folder: Home for the <code>.sqlx</code> and <code>.js</code> files.</p>
</li>
<li><p><code>includes</code> folder: Home for common Javascript constants or functions.</p>
</li>
<li><p><code>workflow_settings.yaml</code>: These are default settings for the Dataform workflow. It consists of the dataform-core version being used, as well as the default project, dataset and location of the BigQuery data. These variables can be overridden. We will go in more detail a bit later.</p>
</li>
</ol>
<p><strong>Quick note</strong>: This step differs based on the Dataform version being used. I’m using v3.0.7, which uses yaml as part of the setup. Prior versions would have had <code>dataform.json</code>, <code>package.json</code> and <code>node_modules</code> in the setup. For more: TODO</p>
<h3 id="heading-data">Data</h3>
<p>The dataset we’ll use is the Brewery dataset again (<a target="_blank" href="https://www.kaggle.com/datasets/ankurnapa/brewery-operations-and-market-analysis-dataset/">https://www.kaggle.com/datasets/ankurnapa/brewery-operations-and-market-analysis-dataset/</a>). For our purposes, let’s compute the daily sales of beer grouped by area. From a pipeline perspective, this follows the classic flow: source → transform → sink executed at a daily cadence with some scheduler.</p>
<h3 id="heading-code">Code</h3>
<p>We have everything we need to begin, let’s look at code. Here is the Git repository for reference: <a target="_blank" href="https://github.com/raghuveer-s/brewery-dataform/tree/basics-with-workflow-settings">https://github.com/raghuveer-s/brewery-dataform/tree/basics-with-workflow-settings</a></p>
<p>First, let’s take a moment and look at the different pieces of a SQLX file:</p>
<ol>
<li><p>A config block. This is a section that which defines type of workflow object, dependencies, tags, options for BigQuery table and more.</p>
</li>
<li><p>SQL operations for data transformations.</p>
</li>
<li><p>Pre and post operation blocks.</p>
</li>
</ol>
<p>And that’s it. Now let’s map to our objective of computing daily sales.</p>
<p>Steps:</p>
<ol>
<li><p>Create a <em>declaration</em> workflow object (Dataform defines different types of workflow objects, refer: <a target="_blank" href="https://cloud.google.com/dataform/docs/sql-workflows">https://cloud.google.com/dataform/docs/sql-workflows</a>).</p>
<ol>
<li><p>All this does is tell Dataform where a source table is and keeps a reference to it, which means all you need is a config block with details that point towards this table.</p>
</li>
<li><p>While this step is optional, I often prefer to create declarations. It helps keep the rest of the code consistent, and best of all, it can be visualized as a DAG in the workspace online.  </p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730670437678/a87e329b-e0a6-4e82-bfe0-ecc553b6dcf6.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>Example: <a target="_blank" href="https://github.com/raghuveer-s/brewery-dataform/blob/basics-with-workflow-settings/definitions/brewery_source.sqlx">https://github.com/raghuveer-s/brewery-dataform/blob/basics-with-workflow-settings/definitions/brewery_source.sqlx</a></p>
</li>
</ol>
</li>
<li><p>Create a SQLX file:</p>
<ol>
<li><p>Since we are talking about data pipelines here, the result of our data transformation needs a home. We can have a table gets completely populated with this result or we can have table that has new data appended to it. The later is called “<em>incremental</em>” workflow object, the former is called “<em>table</em>”. Again, refer here: <a target="_blank" href="https://cloud.google.com/dataform/docs/sql-workflows?hl=en">https://cloud.google.com/dataform/docs/sql-workflows</a></p>
</li>
<li><p>In the config block, specify:</p>
<ol>
<li><p>Workflow object type as <em>incremental</em>.</p>
</li>
<li><p>Tags. These will be useful when we look at “workflow configurations” in the section below.</p>
</li>
<li><p>BigQuery options to the table that is going to hold the result of the transformations.</p>
</li>
</ol>
</li>
<li><p>What follows is the actual SQL that do the work. In this case, we just sum up the sales that happened in a day and group it by area. Pretty simple.</p>
</li>
<li><p>Example: <a target="_blank" href="https://github.com/raghuveer-s/brewery-dataform/blob/basics-with-workflow-settings/definitions/daily_area_sales.sqlx">https://github.com/raghuveer-s/brewery-dataform/blob/basics-with-workflow-settings/definitions/daily_area_sales.sqlx</a></p>
</li>
</ol>
</li>
</ol>
<p>The incremental table code has another section as well. We talked about config block and SQL, Dataform allows optional <code>pre_operations</code> and <code>post_operations</code> blocks as well. As one might expect, the code in this is run before and after the SQL transformations.</p>
<p>There are three methods that can be seen in the code: <code>when()</code>, <code>incremental()</code> and <code>self()</code>. These are some of the so-called “context methods”. <code>when()</code> provides conditional capabilities, <code>incremental()</code> returns true if the table being built is incremental or not, and <code>self()</code> is used to reference the current table being built.</p>
<p>So taken together, what we are doing is just establishing a checkpoint to be used in the where clause, and we start appending latest data if the table exists and is incremental. If not, then we take all the data in the source starting from <code>2023-01-01</code>.</p>
<p>For more about pre and post operations, refer here: <a target="_blank" href="https://cloud.google.com/dataform/docs/table-settings?hl=en#execute-sql-before-table">https://cloud.google.com/dataform/docs/table-settings?hl=en#execute-sql-before-table</a> . And for context methods, there are a few more that can be used, refer here: <a target="_blank" href="https://cloud.google.com/dataform/docs/reference/dataform-core-reference#itablecontext">https://cloud.google.com/dataform/docs/reference/dataform-core-reference#itablecontext</a>.</p>
<p>Commit and push. If you have created a workspace online, then you may also see this error on the right complaining that a table does not exist:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730675060903/638792e9-f78f-4e79-b89e-8cb0881e8513.png" alt class="image--center mx-auto" /></p>
<p>Don’t worry about it. We are creating an “<em>incremental</em>” table. Since we cannot increment something if it didn’t already exist, the error just highlights that. If you go to the non-incremental tab, it all looks good. After you execute this workflow for the first time, the error will disappear.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730675075247/b59a4231-dff3-49e5-8c2a-a575f699667e.png" alt class="image--center mx-auto" /></p>
<p>All that’s left to do is run this. We <em>could</em> do this by selecting the “Start Execution” button on top and following the UI:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730644668508/0c56ce8e-e72e-49cb-82bf-fca7fc43b464.png" alt class="image--center mx-auto" /></p>
<p>But let’s be a bit more formal and execute the code by creating release configurations and workflow configurations.</p>
<h3 id="heading-create-a-release-configuration">Create a release configuration</h3>
<p>You can create a release configuration by the UI or through an API. The API is of course much more powerful and makes a great case for using it with CI/CD but we’ll reserve that later.</p>
<p>Release configuration is easily understood if you think of it like a function.</p>
<p>release_configuration = compile(code, config).</p>
<p>The “release” part of a release configuration is the “<em>compilation result</em>” of the code. Give it a name (release Id) and set the frequency at which these compilation results are to be generated. My settings are quite simple: “production” for the release id and “never” for the frequency.</p>
<p>Since a release configuration is the compiled result of SQLX, if you are using the release configuration as the source for pipeline executions (aka, workflow invocation in Dataform lingo), it means you must create a new compilation whenever a change happens. This is where the API comes in handy. Otherwise, you can just set a frequency and it gets compiled. For this example, since we chose “never”, the compilation result has to be created manually every time a change in the code needs to be reflected in the actual pipeline being executed.</p>
<p>The “configuration” part arises from that you can pass parameters to this compilation. This is where <code>workflow_settings.yaml</code> comes into play again.</p>
<p>We don’t need to override anything in this simple example, but let’s say we have a <code>tablePrefix</code> variable , we could use it store compilation results for staging and production for example. You can even pass custom variables using <code>vars</code>, for example, something like <code>max_lookback</code>. Taking the two together, we could say for staging environment, <code>max_lookback</code> is 1 month, and for production environment <code>max_lookback</code> is 1 year. And then, wrap this in the <code>pre_operations</code> block. Variables in <code>workflow_settings.yaml</code> are referenced using the <code>dataform.projectConfig</code> object. We’ll explore this in a future post.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730658905332/f2942dc9-6422-48d7-a240-debe982aa6a0.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730658996847/52984715-e445-42c7-aecc-e4fda245b324.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-create-a-workflow-configuration">Create a workflow configuration</h3>
<p>Similar to the release configuration, it is easy to mentally map a workflow configuration to a function like so:</p>
<p>workflow_configuration = create_workflow(release_id, config)</p>
<p>The “workflow” part here is just the release configuration which has the compiled code.</p>
<p>The “configuration” part is how often you want to run this workflow (aka, “create the workflow invocation” in Dataform lingo) and which parts of the compiled code that needs to be executed.</p>
<p>The second part is more interesting. A Dataform “action” is the smallest executable unit in a workflow. In this example, the only Action we have is <code>daily_area_sales</code> . But we can have multiple actions in a single workflow. Actions can even depend on each other. An Action can be either selected directly in a workflow configuration or through tags (this is specified in the config block, refer to the SQLX file to see it).</p>
<p>Taken along with the ability to parameterize a release configuration, this becomes very powerful as you can specify, configure and execute pipelines in a very flexible way.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1730660858607/c4c08882-a38e-4b3c-a09c-894d917d02c3.png" alt class="image--center mx-auto" /></p>
<p>And that’s it! Dataform will schedule the execution of this pipeline nightly and you get daily aggregated sales data in the table.</p>
<hr />
<h2 id="heading-concluding-remarks">Concluding remarks</h2>
<p>And with that, a simple pipeline in Dataform is ready. There’s still a lot to talk about of course. In the upcoming posts, we’ll go a bit further in depth on things like variables, action dependencies, data quality tests, CI/CD and observability.</p>
]]></content:encoded></item><item><title><![CDATA[Partition and cluster an existing BigQuery table]]></title><description><![CDATA[Sometimes it so happens that we create or are using a table with data that is non-partitioned but we need to convert this into a partitioned table. A typical use-case is old tables that start accumulate data over time. Quite often, we need the same d...]]></description><link>https://raghuveer.me/partition-cluster-existing-bigquery-table</link><guid isPermaLink="true">https://raghuveer.me/partition-cluster-existing-bigquery-table</guid><category><![CDATA[bigquery]]></category><category><![CDATA[Databases]]></category><category><![CDATA[SQL]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Thu, 17 Oct 2024 21:10:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/Rm3nWQiDTzg/upload/2656d0f9a1788921c10e80990abf1a70.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Sometimes it so happens that we create or are using a table with data that is non-partitioned but we need to convert this into a partitioned table. A typical use-case is old tables that start accumulate data over time. Quite often, we need the same data with extra partitions (and/or clusters), and the rest we can use DDL commands (such as updating metadata).</p>
<p>I’ve created a simple helper script that comes in handy for just that: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/partition-bq-table/partition-bq-table.sql">https://github.com/raghuveer-s/example-code/blob/main/partition-bq-table/partition-bq-table.sql</a></p>
<p>The script does try to preserve some table and column metadata before partitioning, cluster, and copying the data into a new table. For the most part, you should be able to just change the variable names and use it.</p>
<p>Some final points to consider:</p>
<ul>
<li><p>There are some assumptions baked in the script, such as the existence of <code>common</code> dataset where it creates the stored procedures needed.</p>
</li>
<li><p>You probably want to change the region (I have used <code>region-eu</code> in the code). The script makes use of the <code>INFORMATION_SCHEMA</code> and this needs a qualifier (<a target="_blank" href="https://cloud.google.com/bigquery/docs/information-schema-intro#syntax">https://cloud.google.com/bigquery/docs/information-schema-intro#syntax</a>). Note for my future self: This really should be parameterized.</p>
</li>
<li><p>If you want to preserve the same table name for the partitioned table, add a couple of commands for changing table name with <code>ALTER TABLE RENAME</code> followed by <code>DROP TABLE</code> of the old table. Note that, changing a table name does come with some limitations in BigQuery (<a target="_blank" href="https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_table_rename_to_statement">https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_table_rename_to_statement</a>).</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Using Google Cloud Run to process batch jobs]]></title><description><![CDATA[Cloud Run helps you deploy containerized workloads at scale. Using it as a backend for web server use cases is quite well known, but it also can be very useful for large batch jobs that tend to be CPU heavy, especially if the job can be divided into ...]]></description><link>https://raghuveer.me/using-google-cloud-run-to-process-batch-jobs</link><guid isPermaLink="true">https://raghuveer.me/using-google-cloud-run-to-process-batch-jobs</guid><category><![CDATA[Google Cloud Platform]]></category><category><![CDATA[Batch Processing]]></category><category><![CDATA[#cloudrun]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sun, 24 Mar 2024 11:43:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/JfOT-WwO1Ig/upload/d667258b2014afd5f4eaa5497ce852d7.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cloud Run helps you deploy containerized workloads at scale. Using it as a backend for web server use cases is quite well known, but it also can be very useful for large batch jobs that tend to be CPU heavy, especially if the job can be divided into smaller independent pieces. This will be the central focus of this article.</p>
<p>The first half talks briefly about Cloud Run Jobs, and in the second half we will explore a very simple use case with sample code.</p>
<h1 id="heading-use-cases">Use cases</h1>
<p>In a nutshell: Long running jobs! Many use cases that I can think of fall neatly in the data processing or ML category:</p>
<ul>
<li><p>You have a transactional database and need a way to compute aggregations of data without overwhelming the database.</p>
</li>
<li><p>Download and process data from a third party API.</p>
</li>
<li><p>Preprocess data stored on GCS and you cannot use Spark (for whatever reason).</p>
</li>
</ul>
<h1 id="heading-how-does-it-work">How does it work?</h1>
<p>Cloud Run Jobs can be divided into the following components:</p>
<ul>
<li><p>Container. The actual workload to be executed.</p>
</li>
<li><p>Job. It is a higher level construct that references the container workload to run, specifies number of Tasks (refer below), accepts parameters during job creation and so on.</p>
</li>
<li><p>Task. This is the basic unit of parallelism run for a Cloud Run Job. A Task is an executable container instance. Cloud Run can spin up multiple tasks to parallelize your workload. Understanding this is the key to understanding how Cloud Run Jobs work.</p>
</li>
</ul>
<p>In essence, a Cloud Run Job is a template that you fill in which is then later executed by the system. The mandatory fields are:</p>
<ul>
<li><p>Job name.</p>
</li>
<li><p>The container image url.</p>
</li>
<li><p>GCP region.</p>
</li>
<li><p>Number of tasks.</p>
</li>
<li><p>Task timeout.</p>
</li>
<li><p>Task retries.</p>
</li>
</ul>
<p>For a full list, it is informative to glance at the gcloud CLI command: <a target="_blank" href="https://cloud.google.com/sdk/gcloud/reference/run/jobs/create">https://cloud.google.com/sdk/gcloud/reference/run/jobs/create</a>. You can see it accepts several other options such as container arguments, cpu and memory allocated for the container instance and so on.</p>
<h1 id="heading-understanding-tasks">Understanding Tasks</h1>
<p>Tasks are the most important aspect of working with Cloud Run Jobs when it comes to parallel processing.</p>
<p>There are two key points to note:</p>
<ol>
<li><p>Task parallelism. The total number of tasks specified may be different from the total number of Tasks being executed in parallel.</p>
</li>
<li><p>Task independence. Since there are many tasks being executed in parallel, a question that tends to come up is: Are all these tasks executing the same container or can we somehow get each task to execute something different?</p>
</li>
</ol>
<p>Let's get a little deeper into these two concepts and then work through it with a very simple example following that.</p>
<h2 id="heading-task-parallelism">Task parallelism</h2>
<p>For the sake of explanation, let us say that we have a dataset of 100 users and we want to analyze the user records. We can specify the <em>number of tasks</em> to be 100, but it may be that we can specify only 10 tasks to be executed <em>in parallel</em>.</p>
<p>But why only 10 and not all 100 in parallel? Well for one, there are regional limits enforced by Cloud Run (<a target="_blank" href="https://cloud.google.com/run/quotas">https://cloud.google.com/run/quotas</a>). Other reasons could be more technical, say for example, we wouldn't want to overwhelm databases downstream by slamming them with workloads from parallel tasks.</p>
<p>Regardless, the important thing to remember is: These two can be different numbers.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1691788439043/f00bba66-e8b3-464e-87ca-ab472faeeb2e.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-task-independence">Task independence</h2>
<p>We know that Tasks are container instances being executed in parallel. For large batch jobs or long running jobs, it would be perfect if we divide our work into smaller pieces and somehow tell each Task to execute these pieces independently, in parallel.</p>
<p>Cloud Run lets you know which task (which container instance) it is currently executing and the total number of tasks it has through environment variables:</p>
<ul>
<li><p><code>CLOUD_RUN_TASK_INDEX</code>: The task index in this Job. With this, we can know the index of the task which is being executed.</p>
</li>
<li><p><code>CLOUD_RUN_TASK_COUNT</code>: The number of tasks in this Job that are being executed in parallel at any given time.</p>
</li>
</ul>
<p>Note that "maximum number of tasks" is different from "maximum number of tasks running in parallel". The image above should demonstrate the difference. Referring to the resource limits page again, we see the total number of tasks <strong>spawned</strong> at once can be in the thousands range but the number of tasks being <strong>executed in parallel</strong> is in the hundreds range. The tasks must wait for their turn.</p>
<p>Let's now look at how we can use these two variables to retrieve some data and process it independently, we achieve our goal.</p>
<h1 id="heading-example-calculating-daily-user-revenue">Example: Calculating daily user revenue</h1>
<p>Here is our scenario:</p>
<ul>
<li><p>We are running an e-commerce website with a transactional db.</p>
</li>
<li><p>We have a dataset of 100,000 users from a <code>Users</code> table.</p>
</li>
<li><p>There is another table <code>Purchases</code> with records that reference a user whenever a user purchases something on the website.</p>
</li>
<li><p>For simplicity, the user ids are sequential integers starting from 0.</p>
</li>
<li><p>The coding language is Python.</p>
</li>
</ul>
<p>Our goal: Create a job computes the total revenue from a user for that day. Store it in another table called <code>Revenue</code>.</p>
<h2 id="heading-code">Code</h2>
<p><strong>Note</strong>: The code is a contrived example and far from production grade code, but it is enough to demonstrate the two key pieces : <code>CLOUD_RUN_TASK_INDEX</code> and <code>CLOUD_RUN_TASK_COUNT</code>.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">compute</span>():</span>
    task_index = int(os.environ[<span class="hljs-string">"CLOUD_RUN_TASK_INDEX"</span>])
    task_count = int(os.environ[<span class="hljs-string">"CLOUD_RUN_TASK_COUNT"</span>])

    user = os.environ[<span class="hljs-string">"MYSQL_USER"</span>]
    password = os.environ[<span class="hljs-string">"MYSQL_PASSWORD"</span>]
    db_name = os.environ[<span class="hljs-string">"MY_DATABASE"</span>]

    num_users = <span class="hljs-number">10000</span>
    batch_size = num_users / task_count
    start_user_id, end_user_id = [int(batch_size * task_index), int(batch_size * (task_index + <span class="hljs-number">1</span>) - <span class="hljs-number">1</span>)]
</code></pre>
<p>Use <code>task_index</code> to retrieve a set of data points. Since the user ids are incremental in our case, we calculate the batch size based on the total number of tasks that can run in parallel at any given time, which can be used to retrieve the set of user ids to work on.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Retrieve user ids</span>
user_ids = session.execute(
    text(<span class="hljs-string">f"""
        SELECT user_id
        FROM Users
        WHERE user_id &gt;= <span class="hljs-subst">{start_user_id}</span> and user_id &lt;= <span class="hljs-subst">{end_user_id}</span>
    """</span>)
)
</code></pre>
<p>Here we see that we're using a simple select query based on the user id range retrieved above. It should be apparent right away that this is a potential bottleneck, and as mentioned above, one way to mitigate this to some extent is to select the task count environment variable judiciously. The same idea applies when we want to compute and store the result in the database. In other words, any service that is dependent on cloud run task execution must be able to handle the incoming load.</p>
<p>The full code is at: <a target="_blank" href="https://github.com/raghuveer-s/example-code/tree/main/cloud-run-jobs">https://github.com/raghuveer-s/example-code/tree/main/cloud-run-jobs</a></p>
<h3 id="heading-creating-the-job">Creating the job</h3>
<p>This step is quite intuitive with the google cloud console. You can also do the same with the CLI or SDK if it suits your workflow.</p>
<p>In GCP, you can use the artifact registry (or the older container registry) as a registry for your container. Refer to this link: <a target="_blank" href="https://cloud.google.com/artifact-registry/docs/docker/pushing-and-pulling">https://cloud.google.com/artifact-registry/docs/docker/pushing-and-pulling</a> for more on how to do this.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694203793931/6649b62f-a099-47ea-bb06-ca6daae44ecf.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694203801595/b8f08402-44f0-4d0e-beae-162d30859e92.png" alt class="image--center mx-auto" /></p>
<p>Towards the bottom of the screen, you can specify the total number of tasks that can execute in parallel, i.e, task count.</p>
<p>Once you are ready, just click the create job button and Cloud Run will do the rest.</p>
<h1 id="heading-summary">Summary</h1>
<ol>
<li><p>Cloud Run jobs are well suited for large batch jobs. In this case we chose to do data processing, but it can be any long running divisible computation.</p>
</li>
<li><p>Use the task index to retrieve the subset of work we wish to perform computation on. Use task count to control number of parallel executing tasks.</p>
</li>
<li><p>Be mindful of services upstream and downstream if any.</p>
</li>
<li><p>Cloud Run has resource limits which vary by region. Refer to this page: <a target="_blank" href="https://cloud.google.com/run/quotas">https://cloud.google.com/run/quotas</a> to know more about the limits on number of maximum tasks, and number of parallel tasks.</p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Getting answers from data using PySpark]]></title><description><![CDATA[This post attempts to document a small part of a Data Engineer's workflow along with some techniques that help answering data questions from a dataset. On the technical side, we will deal with nested JSON data, touch upon data cleaning and data explo...]]></description><link>https://raghuveer.me/getting-answers-from-data-using-pyspark</link><guid isPermaLink="true">https://raghuveer.me/getting-answers-from-data-using-pyspark</guid><category><![CDATA[spark]]></category><category><![CDATA[json]]></category><category><![CDATA[PySpark]]></category><category><![CDATA[Data exploration]]></category><category><![CDATA[data cleaning ]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Tue, 03 Oct 2023 16:30:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/HfFoo4d061A/upload/d0fd3b63c4b1d3ecd1a32298353a508d.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This post attempts to document a small part of a Data Engineer's workflow along with some techniques that help answering data questions from a dataset. On the technical side, we will deal with nested JSON data, touch upon data cleaning and data exploration. On the "non-technical" side, we will pose questions which are typically requested by business or other data teams.</p>
<h1 id="heading-dataset-and-workflow">Dataset and workflow</h1>
<p>The dataset we will use is US Financial news data hosted at Kaggle. You can find the source for it here: <a target="_blank" href="https://www.kaggle.com/datasets/jeet2016/us-financial-news-articles">https://www.kaggle.com/datasets/jeet2016/us-financial-news-articles</a>. In it, each record is a JSON separated by a newline, i.e, the so-called "JSON newlines" format (<a target="_blank" href="https://jsonlines.org/">https://jsonlines.org/</a>). Picking a record in this dataset, we can see that it this structure: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/schema/raw_data_schema.json">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/schema/raw_data_schema.json</a>. This is a fairly lengthy schema, but it has some nice nested structure with fields and arrays going more than just a level down which makes it convenient to explore several use cases with it.</p>
<p>We will attempt to duplicate a process which is a fairly common:</p>
<ol>
<li><p>You obtain a dataset.</p>
</li>
<li><p>An internal or external user wants some question answered from it.</p>
</li>
<li><p>You as a Data Engineer, must either answer it or facilitate getting the answer in some way.</p>
</li>
</ol>
<p>Since we have financial news, let's try to think of some questions that we can ask from it. For the purposes of the article, the questions might be a bit contrived since we want to get a reasonable technical workout as well, but it should be more or less similar to the kind of questions real world users would like to know.</p>
<p>A few questions that comes to mind are:</p>
<ul>
<li><p>Give me the count of unique authors.</p>
</li>
<li><p>What is the most popular and least popular article in a news website?</p>
</li>
<li><p>How many times does an external domain point to the domain of a news organization?</p>
</li>
<li><p>Give me a list of the most mentioned people.</p>
</li>
<li><p>What is the "sentiment score" of an article?</p>
</li>
</ul>
<p>We will proceed by grouping one or more of these questions under how we intend to process it from a technical perspective.</p>
<h1 id="heading-processing-use-cases">Processing use cases</h1>
<p>For all the cases below, a common variable <code>df</code> is being used. This loads the dataset from the environment variable <code>DATA_PATH</code>.</p>
<pre><code class="lang-python">df = spark.read.json(path=<span class="hljs-string">f"<span class="hljs-subst">{DATA_PATH}</span>/*.json"</span>, schema=<span class="hljs-literal">None</span>)
</code></pre>
<h2 id="heading-flatten-nested-record-to-a-single-row">Flatten nested record to a single row</h2>
<p>One of the most common tasks is to process data for analytical processing. Generally speaking, this means the sink probably has a flat(-ish) schema in a columnar store such as BigQuery, ClickHouse, etc.</p>
<h3 id="heading-processing-a-nested-value">Processing a nested value</h3>
<p>Let's start with the first couple of questions:</p>
<ul>
<li><p>Give me the count of unique authors.</p>
</li>
<li><p>What is the most popular and least popular article in the news website?</p>
</li>
</ul>
<p>For this, we can attempt to flatten the schema into this:</p>
<p><code>url, domain, author, title, socials, text, published_at</code></p>
<p>Looking at the schema, to get values for some the columns above, we have to walk down the path of nested data. This can be easily done in Spark.</p>
<pre><code class="lang-python">flattened_df = (df
    .select(
        F.col(<span class="hljs-string">"thread.url"</span>).alias(<span class="hljs-string">"url"</span>),
        F.col(<span class="hljs-string">"thread.site"</span>).alias(<span class="hljs-string">"domain"</span>),
        F.col(<span class="hljs-string">"author"</span>),
        F.col(<span class="hljs-string">"title"</span>),
        F.col(<span class="hljs-string">"thread.social"</span>).alias(<span class="hljs-string">"socials"</span>),
        F.col(<span class="hljs-string">"text"</span>),
        F.to_timestamp(F.col(<span class="hljs-string">"published"</span>)).alias(<span class="hljs-string">"published_at"</span>)
    )
)
</code></pre>
<p>Now that it is flattened, we can easily count the number of unique authors.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Count of unique authors</span>
c = flattened_df.select(<span class="hljs-string">"author"</span>).distinct().count()
</code></pre>
<p>Implementation notes:</p>
<ul>
<li><p>You can walk down the path to get the deeply nested columns.</p>
</li>
<li><p><code>distinct()</code> and <code>count()</code> will probably "shuffle" your data during processing, so make sure you keep that in mind.</p>
</li>
</ul>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/unique_authors.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/unique_authors.py</a></p>
<p>We used only <code>author</code>, what other answers can we get from data with this schema? Off the top of my head:</p>
<ul>
<li><p>Get across how many domains an author gets published in.</p>
</li>
<li><p>Use it for training summarizers since we have both title and text.</p>
</li>
<li><p>Word clusters for articles over date published to grasp what makes headlines over time.</p>
</li>
</ul>
<p>In a typical workflow, we will have a broad list of use cases and from that a schema will be derived.</p>
<p>Coming back to our original two questions, for the second question, the final schema we want might look like this:</p>
<p><code>domain, most_popular_article, most_popular_article_num_shares, least_popular_article, least_popular_article_num_shares</code></p>
<p>But what is the definition of popularity? Looking at the schema, there seems to be data on the number of times an article gets shared on social media. Let's use that as an indicator and see what comes up.</p>
<p>We have to break this down into multiple steps:</p>
<ol>
<li><p>Get the total number of shares for each record.</p>
</li>
<li><p>Get the most popular and least popular from this.</p>
</li>
<li><p>Perform a join to combine into a single dataset.</p>
</li>
</ol>
<p><strong>Step 1</strong></p>
<pre><code class="lang-python"><span class="hljs-comment"># Get the total shares for an article</span>

social_shares_df = (df
    .select(
        F.col(<span class="hljs-string">"thread.url"</span>).alias(<span class="hljs-string">"url"</span>),
        F.col(<span class="hljs-string">"thread.site"</span>).alias(<span class="hljs-string">"domain"</span>),
        F.col(<span class="hljs-string">"thread.social.gplus.shares"</span>).alias(<span class="hljs-string">"gplus_shares"</span>),
        F.col(<span class="hljs-string">"thread.social.pinterest.shares"</span>).alias(<span class="hljs-string">"pinterest_shares"</span>),
        F.col(<span class="hljs-string">"thread.social.vk.shares"</span>).alias(<span class="hljs-string">"vk_shares"</span>),
        F.col(<span class="hljs-string">"thread.social.linkedin.shares"</span>).alias(<span class="hljs-string">"linkedin_shares"</span>),
        F.col(<span class="hljs-string">"thread.social.facebook.shares"</span>).alias(<span class="hljs-string">"facebook_shares"</span>),
        F.col(<span class="hljs-string">"thread.social.stumbledupon.shares"</span>).alias(<span class="hljs-string">"stumbleupon_shares"</span>),
    )
    .withColumn(<span class="hljs-string">"total_social_shares"</span>, F.col(<span class="hljs-string">"gplus_shares"</span>) + F.col(<span class="hljs-string">"pinterest_shares"</span>) + F.col(<span class="hljs-string">"vk_shares"</span>) + F.col(<span class="hljs-string">"linkedin_shares"</span>) + F.col(<span class="hljs-string">"facebook_shares"</span>) + F.col(<span class="hljs-string">"stumbleupon_shares"</span>))
    .drop(<span class="hljs-string">"gplus_shares"</span>, <span class="hljs-string">"pinterest_shares"</span>, <span class="hljs-string">"vk_shares"</span>, <span class="hljs-string">"linkedin_shares"</span>, <span class="hljs-string">"facebook_shares"</span>, <span class="hljs-string">"stumbleupon_shares"</span>)
    .groupBy(<span class="hljs-string">"url"</span>, <span class="hljs-string">"domain"</span>)
    .agg({
        <span class="hljs-string">"total_social_shares"</span> : <span class="hljs-string">"count"</span>
    })
    .withColumnRenamed(<span class="hljs-string">"count(total_social_shares)"</span>, <span class="hljs-string">"total_shares"</span>)
)
</code></pre>
<p>Implementation notes:</p>
<ul>
<li><p><code>agg()</code> can take either a dict or a list of columns. If it is a dict, then we specify the column that we want to aggregate on as the key, and the aggregate function as value.</p>
</li>
<li><p>Using aggregate in this way results in a column name with the function name and the original column, this can be renamed with <code>withColumnRenamed()</code></p>
</li>
</ul>
<p><strong>Step 2</strong></p>
<pre><code class="lang-python"><span class="hljs-comment"># Get the maximum and minimum shared articles for a news website</span>

most_popular_df = (
    social_shares_df
    .groupBy(<span class="hljs-string">"domain"</span>)
    .agg(
        F.first(<span class="hljs-string">"url"</span>).alias(<span class="hljs-string">"most_popular_article"</span>),
        F.max(<span class="hljs-string">"total_shares"</span>).alias(<span class="hljs-string">"most_popular_article_num_shares"</span>)
    )
)
most_popular_df.createOrReplaceGlobalTempView(<span class="hljs-string">"most_popular_df"</span>)

least_popular_df = (
    social_shares_df
    .groupBy(<span class="hljs-string">"domain"</span>)
    .agg(
        F.first(<span class="hljs-string">"url"</span>).alias(<span class="hljs-string">"least_popular_article"</span>),
        F.min(<span class="hljs-string">"total_shares"</span>).alias(<span class="hljs-string">"least_popular_article_num_shares"</span>)
    )
)
least_popular_df.createOrReplaceGlobalTempView(<span class="hljs-string">"least_popular_df"</span>)
</code></pre>
<p>Implementation notes:</p>
<ul>
<li><p>This time <code>agg()</code> uses a list of columns. Personally, I like this style a lot better. It reads better and aliasing comes right in the same line.</p>
</li>
<li><p><code>createOrReplaceGlobalTempView()</code> creates a table managed by Spark. For a concise explanation on how it works and some ideas on how to use it, refer <a target="_blank" href="https://medium.com/@subashsivaji/types-of-apache-spark-tables-and-views-f468e2e53af2">here[1]</a> and <a target="_blank" href="https://stackoverflow.com/questions/44011846/how-does-createorreplacetempview-work-in-spark">here[2]</a>.</p>
</li>
<li><p>Why are we doing this? It's to demonstrate the <code>spark.sql()</code> API in the next step.</p>
</li>
</ul>
<p><strong>Step 3</strong></p>
<pre><code class="lang-python">final_df = spark.sql(
    <span class="hljs-string">"""
    select * 
    from global_temp.most_popular_df t1
    left join global_temp.least_popular_df t2
    on t1.domain = t2.domain
    """</span>
)
</code></pre>
<p>Implementation note:</p>
<ul>
<li>Do we need to use the <code>spark.sql()</code> API here? No. The coolest thing about Spark is that you can use the functional API or the SQL API. I like mixing and matching it depending on the use case and how it maps to my mental model of the data.</li>
</ul>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/popular_article.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/popular_article.py</a></p>
<p>And here are the final results:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1696321970369/c2107b75-0b1d-4023-bc11-1b0db985110c.png" alt class="image--center mx-auto" /></p>
<p>Well, that was rather disappointing after all that effort. It's quite unlikely the results above represent reality which means our dataset does not have reliable data in this regard. If we want to answer this question, we probably need to look for supplemental data from other datasets, if it exists.</p>
<p>Luckily though, we caught this pretty early, and we didn't build even more use cases on top of this answer, on the assumption such an answer exists. This type of exploration which seeks out answerable questions, interesting patterns, outliers and so on from a dataset is called <strong>Exploratory Data Analysis</strong>. It forms the bedrock on what we can and cannot answer from our data.</p>
<h3 id="heading-processing-nested-arrays">Processing nested arrays</h3>
<p>News articles are often shared on sites that link to the news websites. Let's say you are doing link analysis, a question you might want answered is:</p>
<ul>
<li>How many times does an external domain point to the domain of a news organization?</li>
</ul>
<p>The final schema that we want would be something like this:</p>
<p><code>external_website, news_website, num_links</code></p>
<p>We need to modify the flat schema from the previous section and add a single extra column:</p>
<p><code>url, domain, domain_rank, author, title, external_link, text, published_at</code></p>
<p>But to get <code>external_link</code>, we need to get it from <code>external_links</code> which is an array. We need to <strong>explode</strong> this array. This works quite similarly to a <code>JOIN</code>. For each of the existing records, we take the record values and attach each of the values in the external_links array in turn, thereby creating new records.</p>
<pre><code class="lang-python">flattened_df = (df
    .select(
        F.col(<span class="hljs-string">"thread.url"</span>).alias(<span class="hljs-string">"url"</span>),
        F.col(<span class="hljs-string">"thread.site"</span>).alias(<span class="hljs-string">"domain"</span>),
        F.col(<span class="hljs-string">"thread.performance_score"</span>).cast(<span class="hljs-string">"int"</span>).alias(<span class="hljs-string">"performance_score"</span>),
        F.col(<span class="hljs-string">"author"</span>),
        F.col(<span class="hljs-string">"title"</span>),
        F.explode(<span class="hljs-string">"external_links"</span>).alias(<span class="hljs-string">"external_link"</span>),
        F.col(<span class="hljs-string">"text"</span>),
        F.to_timestamp(F.col(<span class="hljs-string">"published"</span>)).alias(<span class="hljs-string">"published_at"</span>)
    )
)
</code></pre>
<p>Implementation notes:</p>
<ul>
<li><code>explode()</code> can take an array or a map. In the next section, we look at how to explode an array of structs.</li>
</ul>
<p>From this, we need to count the number of times an external website pointed to our news website.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Count number of times an external website points to the news website</span>

links_df = (
    flattened_df
    .select(
        F.expr(<span class="hljs-string">"parse_url(external_link, 'HOST')"</span>).alias(<span class="hljs-string">"external_website"</span>),
        F.col(<span class="hljs-string">"domain"</span>).alias(<span class="hljs-string">"news_website"</span>)
    )
    .groupBy(<span class="hljs-string">"external_website"</span>, <span class="hljs-string">"news_website"</span>)
    .agg(F.count(<span class="hljs-string">"*"</span>).alias(<span class="hljs-string">"num_links"</span>))
    .orderBy(F.desc(<span class="hljs-string">"num_links"</span>))
)
</code></pre>
<p>Implementation notes:</p>
<ul>
<li><code>F.expr()</code> is a middle ground between the functional API and SQL API, it lets you use SQL expressions inside select statements. This is particularly convenient when you want to quickly apply SQL functions on the columns such as the <code>parse_url()</code> function shown above.</li>
</ul>
<p>Here are the top 10 results:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1696332933271/a6d9fbe0-d99c-45a4-8501-ddf241e1daa2.png" alt class="image--center mx-auto" /></p>
<p>It is pretty much what we would expect. News articles are shared on web syndication websites for news content and social media websites.</p>
<p>Notice though, that Thomson Reuters is repeated twice as unique websites due to the <code>www</code> domain, and quite clearly throws off our count. This is an important aspect of Data Cleaning which we look at briefly in the next section.</p>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/num_times_external_domain.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/num_times_external_domain.py</a></p>
<h2 id="heading-process-a-nested-array-of-structs">Process a nested array of structs</h2>
<p>News articles often are about or mentioned people. This is captured in the dataset in the path <code>entities.persons[]</code> . This array contains a struct which has the name of the person and a sentiment expressed by or about this person.</p>
<p>One question that might be interesting is:</p>
<ul>
<li>Give me a list of the most mentioned people.</li>
</ul>
<p>The schema we want for this might look like:</p>
<p><code>person, mentioned_count</code></p>
<pre><code class="lang-python">most_mentioned_df = (
    df
    .select(
        F.explode(F.col(<span class="hljs-string">"entities.persons"</span>)).alias(<span class="hljs-string">"person_struct"</span>)
    )
    .withColumn(<span class="hljs-string">"person"</span>, F.col(<span class="hljs-string">"person_struct.name"</span>))
    .drop(<span class="hljs-string">"person_struct"</span>)
    .groupBy(<span class="hljs-string">"person"</span>)
    .agg(F.count(<span class="hljs-string">"*"</span>).alias(<span class="hljs-string">"mentioned_count"</span>))
    .orderBy(F.desc(<span class="hljs-string">"mentioned_count"</span>))
)

most_mentioned_df.show(<span class="hljs-number">20</span>)
</code></pre>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/most_mentioned.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/most_mentioned.py</a></p>
<p>Running this, we get this output:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1696285081102/e5fd9b8f-1d79-4d61-93ef-e4a76abd4f80.png" alt class="image--center mx-auto" /></p>
<p>Clearly, there are repeated names. This can lead to incorrect counts. Should we resolve this? And if yes, how to resolve this? And to what degree?</p>
<h3 id="heading-importance-of-data-cleaning">Importance of data cleaning</h3>
<p>Data cleaning is a whole topic on its own. As we can see from the above result, it is quite evident that before we pass on this dataset for further usage, we must clean it.</p>
<p>But, what <strong><em>exactly</em></strong> does it mean to clean your data? Spelling mistakes? Unwanted characters? Duplicated records? Unwanted records? Corrupt data? There are so many ways of looking at this, which naturally means there must be a corresponding great number of ways we can "clean" the data. Producing and maintaining high quality datasets is difficult and time consuming. Data Engineers must often grapple with how good is "good enough" for the use case being looked at.</p>
<p>Let's demonstrate a rough data cleaning procedure for this particular case. We note that:</p>
<ul>
<li><p>We are dealing with a person's name here, which generally has a first name and last name.</p>
</li>
<li><p>Since it is the US, when there a person's name mentioned in isolation without reference to first or last name, it will most likely be the last name keeping in mind this is a news dataset which means there most likely will be a degree of formality involved. This means we can "group" together the references of a person who has the full name and only the last name.</p>
</li>
<li><p>We can ignore spelling mistakes and unwanted characters for simplification.</p>
</li>
</ul>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/most_mentioned_cleaned.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/most_mentioned_cleaned.py</a></p>
<p>Here are the top 20 results:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1696347140552/12aa4b12-dc02-4abc-8824-0bcf580d4230.png" alt class="image--center mx-auto" /></p>
<p>Doesn't look too bad. The duplicates problem from before is gone and the results for Donald Trump add perfectly. Let's explore a bit more. This is what we get when filtering for just "trump":</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1696342863760/6a6275cd-ba0b-41a2-ae06-6cc94455e6a9.png" alt class="image--center mx-auto" /></p>
<p>Now things get a bit murkier. But before the question "how to resolve it", we must ask "do we really need to resolve it"? Percentage wise, mentions for <code>donald j. trump</code> over <code>donald trump</code> comes to 0.3%. Is it worth the effort to clean it even further? And remember, this is just for one column, and one value inside that column. If we really do decide for this use case that we need a higher accuracy, then we will likely need to use a NER model (<a target="_blank" href="https://www.machinelearningplus.com/nlp/training-custom-ner-model-in-spacy/">example using Spacy</a>[3]), preferably trained on a dataset of famous personalities in the US but this arguably requires more effort than our quick and dirty approach.</p>
<p>The key takeaways are:</p>
<ul>
<li><p>There are different paths to clean data: Removing nulls, duplicates, syntactical correctness, semantic correctness and so on.</p>
</li>
<li><p>It is a balancing act of effort, time and data reliability.</p>
</li>
</ul>
<h2 id="heading-using-udfs">Using UDFs</h2>
<p>Let's look at a slightly more complicated use case. Each news article has person, location and organization entities with a sentiment. This naturally prompts the question:</p>
<ul>
<li>What is the "sentiment score" of an article?</li>
</ul>
<p>Let's make some assumptions on the dataset and define how to compute a sentiment score. Keeping it simple, for any entity we will map the "sentiment" to a number. A positive sentiment is +1, negative is -1 and no sentiment is 0. We will also use <a target="_blank" href="https://textblob.readthedocs.io/en/dev/index.html">TextBlob[4]</a> which is a text processing package that has sentiment polarity built into it. Our sentiment score is the average of both of these numbers.</p>
<p>This goes beyond what is built into Spark by default. But Spark provides a mechanism by which we can define our own functions that can be executed on the dataset. This is the user defined function, shortened to UDF.</p>
<p>The final schema we want is:</p>
<p><code>url, sentiment_score</code></p>
<p>We can get this pretty quickly from what we have already learned.</p>
<pre><code class="lang-python">flattened_df = (df
    .select(
        F.col(<span class="hljs-string">"thread.url"</span>).alias(<span class="hljs-string">"url"</span>),
        F.col(<span class="hljs-string">"entities.persons"</span>).alias(<span class="hljs-string">"persons"</span>),
        F.col(<span class="hljs-string">"entities.locations"</span>).alias(<span class="hljs-string">"locations"</span>),
        F.col(<span class="hljs-string">"entities.organizations"</span>).alias(<span class="hljs-string">"organizations"</span>),
        F.col(<span class="hljs-string">"text"</span>),
        F.to_timestamp(F.col(<span class="hljs-string">"published"</span>)).alias(<span class="hljs-string">"published_at"</span>)
    )
)
</code></pre>
<p>From this, we need to:</p>
<ul>
<li><p>Process the sentiment values from the persons, locations and organizations column.</p>
</li>
<li><p>Use a sentiment analyzer on the text column.</p>
</li>
</ul>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">sentiment_score_1</span>(<span class="hljs-params">persons:List, locations:List, organizations:List</span>) -&gt; float:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">fn</span>(<span class="hljs-params">s:str</span>):</span>
        <span class="hljs-keyword">if</span> s == <span class="hljs-string">"positive"</span>:
            <span class="hljs-keyword">return</span> <span class="hljs-number">1</span>
        <span class="hljs-keyword">elif</span> s == <span class="hljs-string">"negative"</span>:
            <span class="hljs-keyword">return</span> <span class="hljs-number">-1</span>
        <span class="hljs-keyword">else</span>:
            <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>

    s1 = sum(map(fn, [p.sentiment <span class="hljs-keyword">for</span> p <span class="hljs-keyword">in</span> persons])) / max(len(persons), <span class="hljs-number">1</span>)
    s2 = sum(map(fn, [l.sentiment <span class="hljs-keyword">for</span> l <span class="hljs-keyword">in</span> locations])) / max(len(locations), <span class="hljs-number">1</span>)
    s3 = sum(map(fn, [o.sentiment <span class="hljs-keyword">for</span> o <span class="hljs-keyword">in</span> organizations])) / max(len(organizations), <span class="hljs-number">1</span>)

    <span class="hljs-keyword">return</span> (s1 + s2 + s3) / <span class="hljs-number">3</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">sentiment_score_2</span>(<span class="hljs-params">text</span>) -&gt; float:</span>
    <span class="hljs-keyword">return</span> TextBlob(text).sentiment.polarity

<span class="hljs-comment"># Creating an user defined function</span>
sscore1 = F.udf(sentiment_score_1, FloatType())
sscore2 = F.udf(sentiment_score_2, FloatType())

<span class="hljs-comment"># Using udf in the transforms</span>
final_df = (
    flattened_df
    .select(
        F.col(<span class="hljs-string">"url"</span>), 
        sscore1(F.col(<span class="hljs-string">"persons"</span>), F.col(<span class="hljs-string">"locations"</span>), F.col(<span class="hljs-string">"organizations"</span>)).alias(<span class="hljs-string">"sscore1"</span>),
        sscore2(F.col(<span class="hljs-string">"text"</span>)).alias(<span class="hljs-string">"sscore2"</span>)
    )
    .withColumn(<span class="hljs-string">"sentiment_score"</span>, (F.col(<span class="hljs-string">"sscore1"</span>) + F.col(<span class="hljs-string">"sscore2"</span>)) / <span class="hljs-number">2</span>)
    .drop(<span class="hljs-string">"sscore1"</span>, <span class="hljs-string">"sscore2"</span>)
    .orderBy(<span class="hljs-string">"sentiment_score"</span>, ascending=<span class="hljs-literal">False</span>)
)
</code></pre>
<p>One key point to remember about UDFs is that they are essentially the same as your sql functions, which means they act on the columns you specify, for that row.</p>
<p>The code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/sentiment_analysis.py">https://github.com/raghuveer-s/example-code/blob/main/spark-json-processing/sentiment_analysis.py</a></p>
<h1 id="heading-summary">Summary</h1>
<p>In conclusion, we took a high level view on how Data Engineers are tasked to help answering data questions, how to translate this into PySpark, and a few points on Data Exploration and Data Cleaning.</p>
<p>Where to go from here? There are so many directions. Taking these scripts into production using AWS or GCP is a natural progression from what we just talked about. In terms of utility, Data Cleaning, enforcing Data Quality, and in general producing reliable datasets will be both useful and highly regarded by your teams and team members. And of course, the last section progresses neatly into Machine Learning pipelines and ML Ops.</p>
<p>The full code is here: <a target="_blank" href="https://github.com/raghuveer-s/example-code/tree/main/spark-json-processing">https://github.com/raghuveer-s/example-code/tree/main/spark-json-processing</a></p>
<h1 id="heading-references">References</h1>
<p>[1] Types of tables in Spark : <a target="_blank" href="https://medium.com/@subashsivaji/types-of-apache-spark-tables-and-views-f468e2e53af2">https://medium.com/@subashsivaji/types-of-apache-spark-tables-and-views-f468e2e53af2</a></p>
<p>[2] How does createReplaceTempView() work in Spark? : <a target="_blank" href="https://stackoverflow.com/questions/44011846/how-does-createorreplacetempview-work-in-spark">https://stackoverflow.com/questions/44011846/how-does-createorreplacetempview-work-in-spark</a></p>
<p>[3] Custom NER using Spacy : <a target="_blank" href="https://www.machinelearningplus.com/nlp/training-custom-ner-model-in-spacy/">https://www.machinelearningplus.com/nlp/training-custom-ner-model-in-spacy/</a></p>
<p>[4] TextBlob : Text processing library (<a target="_blank" href="https://textblob.readthedocs.io/en/dev/index.html">https://textblob.readthedocs.io/en/dev/index.html</a>)</p>
]]></content:encoded></item><item><title><![CDATA[Parquet format - A deep dive : Part 4]]></title><description><![CDATA[After a lot of theory it's finally to talk about the code. Since there is a lot going on in the codebase, this will still be quite high level but it should serve as a good starting point.
parquet-mr consists uses maven multi modules approach, the mod...]]></description><link>https://raghuveer.me/parquet-format-a-deep-dive-part-4</link><guid isPermaLink="true">https://raghuveer.me/parquet-format-a-deep-dive-part-4</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[Parquet]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sat, 29 Jul 2023 11:28:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/fyeOxvYvIyY/upload/740cd1382423ecb68425019a32049432.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After a lot of theory it's finally to talk about the code. Since there is a lot going on in the codebase, this will still be quite high level but it should serve as a good starting point.</p>
<p>parquet-mr consists uses maven multi modules approach, the modules that I found myself spending the most time on are: <code>parquet-common</code>, <code>parquet-column</code>, and <code>parquet-hadoop</code>. The codebase follows object oriented principles beautifully so there are many classes. But, the responsibility of each is very specific and it all comes together very elegantly to perform the operations required.</p>
<p><strong>Note:</strong> This is a simplified class diagram with many classes missing, and some liberties taken with the associations, inner classes and so on. The overall structure roughly falls along the same lines.</p>
<h1 id="heading-writing-data">Writing data</h1>
<p>Here is a rough class diagram for the write process.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1690022141434/b369b8fe-1e97-4d12-8975-a2477a865a7b.png" alt class="image--center mx-auto" /></p>
<p>There are various classes that handle writing data into pages, column chunks and so on.</p>
<p>Each column of data has a <code>ColumnDescriptor</code> which describes its path, type and holds the maximum repetition level and definition level. This along with <code>ColumnChunkPageWriter</code> for each column form the collection of writes for each column through which data is written. This is maintained in the <code>ColumnWriteBase</code> subclasses which itself is generated for the specific column, from a provider through <code>ColumnWriteBaseStore</code> and its subclasses.</p>
<p>The actual writing itself happens through <code>InternalParquetRecordWriter&lt;T&gt;</code> in the <code>parquet-hadoop</code> module. During writing, we must keep in mind the encoding in which data is written, this happens through subclasses of <code>ValueWriter</code> , and since the record itself can have a nested structure, it needs to be serialized-deserialized appropriately which is done through <code>ColumnIO</code> and its subclasses. Finally, subclasses of <code>Statistics</code> maintain the metadata through which optimizations are performed for the read side.</p>
<h1 id="heading-reading-data">Reading data</h1>
<p>Class diagram for the read process.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1690022157042/753bd769-47dd-48f0-ac27-326099651d64.png" alt class="image--center mx-auto" /></p>
<p>As you can see, there are prats of the diagram that are structurally very similar to the write side of the equation, except it performs the read operations.</p>
<p>Skipping over the counterparts which are mirrored from the write side, the new claases here are primarily <code>RecordMaterializer</code> and <code>RecordReaderImplementation</code>. <code>RecordMaterializer</code> has subclasses converts the read data into the appropriate format.</p>
<p>For example, Avro uses <code>AvroRecordMaterializer</code>, Protobuf uses <code>ProtoRecordMateralizer</code> and Spark uses <code>ParquetRecordMaterializer.scala</code>. <code>RecordReaderImplementation</code> is used to assemble the records and read its data, it constructs the automaton in the previous post used to read the shredded records, and holds a reference to the materializer.</p>
<h1 id="heading-summary">Summary</h1>
<p>And with that, we come to the end of the four part series. There's of course a lot more that I had to skip out, I highly recommend browsing through the parquet-mr codebase to get a sense for what this file format can do.</p>
<p>Personally, reading the code in parquet-mr was a difficult but rewarding experience. I do not claim to have understood it perfectly, but it was certainly a fun trip navigating through it and trying to figure things out. Shout out to the devs for this incredible project!</p>
]]></content:encoded></item><item><title><![CDATA[Parquet format - A deep dive : Part 3]]></title><description><![CDATA[Previously, we talked about how to parquet writes data. In this article, we will talk about how parquet reads data. Once again, parquet borrows from Dremel and uses its record assembly algorithm. We also talk briefly on some clever optimizations parq...]]></description><link>https://raghuveer.me/parquet-format-a-deep-dive-part-3</link><guid isPermaLink="true">https://raghuveer.me/parquet-format-a-deep-dive-part-3</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[Parquet]]></category><category><![CDATA[spark]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sun, 23 Jul 2023 13:09:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/fyeOxvYvIyY/upload/740cd1382423ecb68425019a32049432.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Previously, we talked about how to parquet writes data. In this article, we will talk about how parquet reads data. Once again, parquet borrows from Dremel and uses its record assembly algorithm. We also talk briefly on some clever optimizations parquet does to speed up data reads.</p>
<h2 id="heading-record-assembly-algorithm">Record assembly algorithm</h2>
<p>The basic idea is that we need to assemble the original record from the flattened data using its repetition level and definition level.</p>
<p><strong>NOTE:</strong> What is described below is based on Dremel's original paper and parquet-mr. Spark implements a vectorized parquet reader based off parquet-mr, but the code flow is slightly different from what is described here and is mentioned briefly towards the end.</p>
<p>Let's revisit some of the key points from <a target="_blank" href="https://hashnode.com/post/clbj4yp9s0alxmlnvdfds9jgz">part 2</a> that will be useful to reassemble the record:</p>
<ul>
<li><p>Each data record has a tree structure with potentially multiple levels. The leaf of this structure represents the unique columns.</p>
</li>
<li><p>Data can potentially repeat.</p>
</li>
<li><p>Data can be optional.</p>
</li>
<li><p>Repetition level indicates at what "repeated" field the value as repeated.</p>
</li>
<li><p>Definition level is used to capture the level up to which optional values exist (mainly so that we can capture the levels at which a value or NULL occurs).</p>
</li>
<li><p>The final data had flattened the tree structure into columns and associated them repetition and definition levels.</p>
</li>
</ul>
<p>In simple terms: We have a multiple lists of leaf nodes with some numbers attached to it. Therefore, to reconstruct the original hierarchy we need three things:</p>
<ul>
<li><p>Access specific leaf nodes inside a list. Each list is just your column data.</p>
</li>
<li><p>Jump from list to list.</p>
</li>
<li><p>Place the nodes at the correct levels.</p>
</li>
</ul>
<p>All we need now is to specify how to "access" lists of data, "jump" across lists and "place" the nodes.</p>
<h3 id="heading-finite-state-machine">Finite state machine</h3>
<p>Let's visualize some of the columns and how such a jump might look like. Going with the example data from <a target="_blank" href="https://raghuveer.me/parquet-format-a-deep-dive-part-2">part 2</a> if we try to jump across columns while simultaneously trying to reconstruct the original hierarchy we can imagine doing something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689520795005/5eccb8c2-9058-41cc-bb90-8e278db18ee5.png" alt class="image--center mx-auto" /></p>
<p>Even in this simplified example, rules for parsing seem to be emerging:</p>
<ul>
<li><p>Go through the columns in the same order as it is in the schema.</p>
</li>
<li><p>When a "0" is encountered in the repetition level, move to the next field.</p>
</li>
<li><p>Otherwise drain all the data which has a common repetition level.</p>
</li>
</ul>
<p>In the above example, we go from Links.Backward to Links.Forward column with RL of 1, and drain all these fields of the same RL. Once that is over, we move to RL of 0. At which point we observe its value and move to the next field.</p>
<p>And what about definition level? Recall that DL helps know if there are NULL values along the path of the node, and if there are optional values along the path. Which means, with DL we can know the depth of the node in the record we are building, especially given that we know the maximum definition level depth directly from the schema.</p>
<p>Summarizing and drawing parallels from the previous section:</p>
<ul>
<li><p>Use schema to get the column order, maximum definition level.</p>
</li>
<li><p>Use RL to go from column to column.</p>
</li>
<li><p>Use DL at runtime and max definition levels to place the node.</p>
</li>
</ul>
<p>The full state machine for the example data, taken from the Dremel paper:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689521487262/3d7c06c0-d5c8-46a5-86c1-97a8e35b032f.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-notable-optimizations">Notable optimizations</h2>
<p>When dealing with large dataset, we need to think of improving compute time. Most of the tricks below try to accomplish this in one way or another.</p>
<h3 id="heading-predicate-pushdown">Predicate pushdown</h3>
<p>This is a fairly common optimization borrowed from databases. The idea is that instead of gathering the results and then filtering, we attempt to pushdown the conditions so that the data is filtered at the lower layers before gathering them.</p>
<p>Fun fact: I was curious how old this technique is and a casual search on google scholar led to this paper<a target="_blank" href="https://www.researchgate.net/profile/Inderpal-Mumick/publication/2754592_Query_Optimization_by_Predicate_Move-Around/links/0f317534d437e49755000000/Query-Optimization-by-Predicate-Move-Around.pdf">[1]</a> which references predicate pushdown to one of Jeffrey Ullman's<a target="_blank" href="https://en.wikipedia.org/wiki/Jeffrey_Ullman">[2]</a> book from 1989!</p>
<h3 id="heading-filtering-row-groups">Filtering row groups</h3>
<p>Parquet can use the block metadata which allows it to skip over whole pages of data. It does this mostly through two important interfaces called <code>ColumnIndex</code> and <code>OffsetIndex</code>. Briefly, the metadata has statistics such as min, max values, null counts which allows parquet to scan through the data and skip pages. The original issue link<a target="_blank" href="https://issues.apache.org/jira/browse/PARQUET-922">[3]</a> and a corresponding google doc<a target="_blank" href="https://docs.google.com/document/d/1sBACp8Lbutuj1Zxdowvsrlm8ku4BFxf8U_Do5K2wSO4/edit">[4]</a> make for interesting reads.</p>
<h3 id="heading-partial-aggregation-spark">Partial aggregation (Spark)</h3>
<p>If the requested query has aggregations, then can choose to perform partial aggregations by using Parquet's column statistics instead of returning rows to Spark and aggregating upstream. This way, compute is saved and data movement also reduces.</p>
<h3 id="heading-vectorized-reader-spark">Vectorized reader (Spark)</h3>
<p>Spark has a vectorized reader implementation (enabled through <code>spark.sql.parquet.enableVectorizedReader</code> configuration setting) to read parquet data which according to the docs is based off the code in parquet-mr but it's Spark own implementation of the read operation. The basic principle is that in the vectorized approach it reads columnar data in batches, instead of the non-vectorized approach which follows a more iterative style approach to loading data. But there is a lot more to vectorized style of data processing and it can go quite low level. For more, refer to this talk<a target="_blank" href="https://www.youtube.com/watch?v=lrw44LlSp0s&amp;t=600s">[5]</a> and this video<a target="_blank" href="https://www.youtube.com/watch?v=YcOVzx2AKc4">[6]</a> (both are from Databricks).</p>
<h2 id="heading-summary">Summary</h2>
<p>In short, we covered how recreate the flattened record from the repetition level and definition levels captured earlier. We also saw some tricks parquet uses to speed up its reads, the driving idea being to find ways to only process as much data as required and no more.</p>
<h1 id="heading-references">References</h1>
<p>[1] Query Optimization by Predicate Move Around</p>
<p>[2] Jeffrey Ullman</p>
<p>[3] PARQUET-922 Jira issue</p>
<p>[4] PARQUET-922: SortColumnIndex Layout to Support Page Skipping</p>
<p>[5] Recent Parquet Improvements in Apache Spark</p>
<p>[6] Enabling Vectorized Engine in Apache Spark</p>
]]></content:encoded></item><item><title><![CDATA[Automatically add partitions in Athena]]></title><description><![CDATA[Using S3 and Athena is great for data storage and retrieval using queries. But when I first started using it, one common problem that came up fairly quickly is: How can I add new partitions automatically? The issue was this: Partitioned data was gett...]]></description><link>https://raghuveer.me/automatically-add-partitions-in-athena</link><guid isPermaLink="true">https://raghuveer.me/automatically-add-partitions-in-athena</guid><category><![CDATA[AWS]]></category><category><![CDATA[aws athena]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sun, 18 Dec 2022 19:37:09 GMT</pubDate><content:encoded><![CDATA[<p>Using S3 and Athena is great for data storage and retrieval using queries. But when I first started using it, one common problem that came up fairly quickly is: How can I add new partitions automatically? The issue was this: Partitioned data was getting created by some ETL process, but it did not getting reflected when querying in Athena.</p>
<p>There are a few ways to add partitions information for Athena:</p>
<ul>
<li><p><code>MSCK REPAIR TABLE</code> query</p>
</li>
<li><p><code>ALTER TABLE ADD PARTITIONS</code> query</p>
</li>
<li><p>AWS Glue Crawler</p>
</li>
<li><p>Partition projection</p>
</li>
</ul>
<p>In this introductory article, we will go over these techniques.</p>
<h3 id="heading-msck-repair-table">MSCK REPAIR TABLE</h3>
<p>If you have hive style partitions, this is the easiest one and typically the first thing most folks try. The command is simple:</p>
<pre><code class="lang-sql">MSCK <span class="hljs-keyword">REPAIR</span> <span class="hljs-keyword">TABLE</span> table_name
</code></pre>
<p>But it's also my least preferred option. The biggest reason is : <strong>It can be slow.</strong> If you have lots of partitions, then the command will take time to finish.</p>
<p><strong>So why does MSCK REPAIR TABLE slow down?</strong></p>
<p>There are a couple of places on the internet that discuss this problem quite well <a target="_blank" href="https://athena.guide/articles/msck-repair-table/">here[1]</a> and <a target="_blank" href="https://stackoverflow.com/questions/53667639/what-does-msck-repair-table-do-behind-the-scenes-and-why-its-so-slow">here[2]</a>. To summarize: It's because of recursively listing the directory structure for all the partitions + hive metastore checking/modifying the partitions.</p>
<p><strong>How to automate?</strong></p>
<p>Don't.</p>
<p>MSCK REPAIR TABLE is a nice command to know and use, but for the reasons above, unless the number of partitions you have is very small, it's not worth automating it. (If you must know, the process is almost identical to alter table method, just change the query).</p>
<h3 id="heading-alter-table-add-partitions">ALTER TABLE ADD PARTITIONS</h3>
<p>The second way is to directly add the partition information through Athena.</p>
<p>From the AWS documentation, the command syntax is:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> table_name <span class="hljs-keyword">ADD</span> [<span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span>]
  <span class="hljs-keyword">PARTITION</span>
  (partition_col1_name = partition_col1_value
  [,partition_col2_name = partition_col2_value]
  [,...])
  [LOCATION <span class="hljs-string">'location1'</span>]
  [<span class="hljs-keyword">PARTITION</span>
  (partition_colA_name = partition_colA_value
  [,partition_colB_name = partition_colB_value
  [,...])]
  [LOCATION <span class="hljs-string">'location2'</span>]
  [,...]
</code></pre>
<p>To summarize, this is an Athena query that we are trying to schedule.</p>
<p><strong>How to automate?</strong></p>
<p>The most familiar way should be either use a cron on a machine or schedule a lambda function. My preference is to use Lambda if there are already be one or more data engineering repositories. By using SAM, creating, deploying and maintaining a new lambda function becomes another piece of code.</p>
<p><strong>Caveats</strong></p>
<p>There are a couple of issues that need to addressing in this technique:</p>
<ul>
<li><p>Permissions. Lambda functions need permissions to execute Athena queries. If you are using SAM, this can be provided in the template yml file as a policy. The simplest way is to use <code>AmazonAthenaFullAccess</code> policy - but like the policy name says - it allows full access to Athena. An inline policy or a custom policy is probably the right way to go long term.</p>
</li>
<li><p>Handling retries or error handling during lambda function execution. For example, if Athena is handling too many requests it might throw <code>TooManyRequestsException</code> so we will need to handle a case like that.</p>
</li>
</ul>
<h3 id="heading-aws-glue-crawler">AWS Glue crawler</h3>
<p>Glue crawler is an AWS tool that scans data from a data store and populates the Glue data catalog with metadata. Crawlers are fairly straightforward because you need to only point to the data source and configure it a little then it does most of the work for you.</p>
<p><strong>How to automate?</strong></p>
<p>Triggering the crawler will update the metadata in the catalog. And Glue crawler gives you this option to do this crawler creation itself. In the last step, you can schedule the crawler at a number of frequencies (hourly, daily, weekly, monthly, or custom schedule with a cron syntax).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670959202658/jy1N8IHnf.png" alt /></p>
<p><strong>Caveats</strong></p>
<p>A word of caution when using Glue crawler: In my opinion, if the structure of your data like schema, partitions, etc do not change that much then it is quite convenient. But if for example, your schema changes over time, then in my experience, Glue crawler has been a hit or a miss.</p>
<h3 id="heading-athena-partition-projection">Athena partition projection</h3>
<p>The last approach and in my opinion the one that should be used, is partition projection provided by Athena. In short, what it does is it gets the partitions information from the table properties directly instead of loading the partitions from AWS Glue Catalog and then pruning them.</p>
<p><strong>How to automate?</strong></p>
<p>Getting started with partition projection is fairly easy. As mentioned, we need to add this projection information into the table properties used by Athena.</p>
<p>We can do this through AWS Glue Catalog if you have existing tables. Steps:</p>
<ol>
<li><p>Click on your table in the catalog.</p>
</li>
<li><p>Click on Edit table.</p>
</li>
<li><p>Then in the table properties section, add key-value pairs based on the data type of partition you want to add.</p>
</li>
</ol>
<p>For example, a common data type for partition is date. For the date type, there are three required key-value pairs: <code>projection.columnName.type</code> , <code>projection.columnName.range</code> and <code>projection.columnName.format</code>. Respectively, these are used to specify the data type of the partition which in this case is date, the range which is a comma separated two element list that specify the minimum and maximum of values, and the date format (eg: dd-MM-yyyy). There are other data types which are supported as well: enum, integer and "injected". Each of them come with their own key-value pairs for using partition projection. Refer <a target="_blank" href="https://docs.aws.amazon.com/athena/latest/ug/partition-projection-supported-types.html#partition-projection-date-type">here[3]</a> for more information on supported data types.</p>
<p>If you are creating new tables through Athena, you can add the key-value pairs in <code>TBLPROPERTIES</code> in the create table query.</p>
<p>There is a lot more to it than what we can talk about partition projection, it is quite a powerful feature. I highly recommend referring to the <a target="_blank" href="https://docs.aws.amazon.com/athena/latest/ug/partition-projection.html">AWS docs[4]</a> for more.</p>
<h1 id="heading-summary">Summary</h1>
<p>In this article we looked at a few techniques to add partitions in Athena and some of their associated pros and cons. In most situations, partition projection should work out just fine. But if you ever come across a situation where it does not, then I hope the other techniques can come to your aid. I'll try to add code to some of these techniques in the future to help illustrate these techniques further.</p>
<h1 id="heading-references">References</h1>
<p>[1] <a target="_blank" href="https://athena.guide/articles/msck-repair-table/">https://athena.guide/articles/msck-repair-table/</a></p>
<p>[2] <a target="_blank" href="https://stackoverflow.com/questions/53667639/what-does-msck-repair-table-do-behind-the-scenes-and-why-its-so-slow">https://stackoverflow.com/questions/53667639/what-does-msck-repair-table-do-behind-the-scenes-and-why-its-so-slow</a></p>
<p>[3] <a target="_blank" href="https://docs.aws.amazon.com/athena/latest/ug/partition-projection-supported-types.html#partition-projection-date-type">https://docs.aws.amazon.com/athena/latest/ug/partition-projection-supported-types.html#partition-projection-date-type</a></p>
<p>[4] <a target="_blank" href="https://docs.aws.amazon.com/athena/latest/ug/partition-projection.html">https://docs.aws.amazon.com/athena/latest/ug/partition-projection.html</a></p>
]]></content:encoded></item><item><title><![CDATA[Parquet format - A deep dive : Part 2]]></title><description><![CDATA[In this article we will get into how data is written as parquet format. To do that, one of the first thing we must do is talk about Dremel.
Dremel was built at Google, it is web scale analytical system for performing ad-hoc queries on nested data. Pa...]]></description><link>https://raghuveer.me/parquet-format-a-deep-dive-part-2</link><guid isPermaLink="true">https://raghuveer.me/parquet-format-a-deep-dive-part-2</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[Parquet]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Sun, 11 Dec 2022 09:00:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/fyeOxvYvIyY/upload/740cd1382423ecb68425019a32049432.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In this article we will get into how data is written as parquet format. To do that, one of the first thing we must do is talk about Dremel.</p>
<p>Dremel was built at Google, it is web scale analytical system for performing ad-hoc queries on nested data. Parquet takes a <em>page</em> from Dremel and uses its record shredding and record assembly algorithms to store and retrieve nested data efficiently.</p>
<p>Record shredding is how data is broken into columnar data, and then written into parquet files. The reverse procedure (record assembly) is covered in part 3.</p>
<h2 id="heading-nested-data">Nested data</h2>
<p>Before beginning, let's take a step back and look at the structure of the incoming data. Speaking generally, it can have the following characteristics:</p>
<ul>
<li><p>Nested sections</p>
</li>
<li><p>Repeatable sections</p>
</li>
<li><p>Optional or required sections</p>
</li>
</ul>
<p>This can be understood better when represented with a schema.</p>
<h3 id="heading-schema">Schema</h3>
<p>A schema is a structural view of the data. Let's use the same example as in the Dremel paper:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670344342740/9qdWHkvjO.png" alt /></p>
<p>Minor note: The Dremel paper uses <a target="_blank" href="https://developers.google.com/protocol-buffers/">Protobuf</a>[<a target="_blank" href="https://developers.google.com/protocol-buffers/">1</a>] to represent the schema but you can use other protocols like <a target="_blank" href="https://thrift.apache.org/">Thrift</a>[<a target="_blank" href="https://thrift.apache.org/">2</a>] as well.</p>
<p>Looking at the above schema in a different way, we can think of it as a of group of nodes of size n &gt;= 1 which are optional / required / repeating. The parquet format requires that we attempt to convert this into a flat structure.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670349774113/_IVN-jscq.png" alt /></p>
<p>All the values of this structure are present in the leaves of the tree. Looking at it another way, the data can be flattened if we know the full path to the leaf + we know the depth of the node + we are able to represent optional / required nodes + we can represent if the repetition of fields. In other words, if we can reduce each path to a column and are able to generate this metadata for the nested incoming data, then along with the actual values themselves we have everything we need to convert the nested data into a flattened form.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670349760180/dTkM7-w5S.png" alt /></p>
<p>In the above example, if we look at the schema, <code>Name.Language.Code</code> is part of <code>Name</code> node which can repeat, <code>Language</code> node which can also repeat and <code>Code</code> is required. Thus for each <code>Name.Language.Code</code> value that exists in the data, if we can somehow capture the levels in the tree at which it repeats, and whether there are any optional along with the value then in principle we have everything we need to convert to and fro. This is what <strong>repetition levels</strong> and <strong>definition levels</strong> do.</p>
<p>To understand this better, let's go over them with an example.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670351646389/mxTr5qWVh.png" alt /></p>
<h3 id="heading-repetition-level">Repetition level</h3>
<p>When we have nested data with multiple levels and there can be data at some level that is a) optional and b) repeated (ie, a list) we need to know one thing : At what level should I start create a new list for this value? In the Dremel paper this idea is defined as: "It tells us <strong>at what repeated field in the field’s path the value has repeated.</strong>". In the above example, let's look at the <code>Name.Language.Code</code> path (column) again.</p>
<p>Observations:</p>
<ul>
<li><p>Going by the schema, <code>Name</code> and <code>Language</code> are groups which can be repeated. They do in fact repeat in record 1. <code>Name</code> node repeats three times, <code>Language</code> node repeats twice in the first name node, and once in the last one. And it occurs just once in record 2.</p>
</li>
<li><p><code>Code</code> is a required field. Which means if <code>Language</code> exists, <code>Code</code> must exist as well as per the schema otherwise it is an invalid document.</p>
</li>
</ul>
<p>Let's try to flatten this. Appending a number for simplicity and going in order, we have the following observations:</p>
<table><tbody><tr><td><p><strong>Field</strong></p></td><td><p>Value</p></td><td><p>Create new list for this value?</p></td><td><p>New record?</p></td></tr><tr><td><p>Name.Language.Code(1)</p></td><td><p>en-US</p></td><td><p>Y</p></td><td><p>Y</p></td></tr><tr><td><p>Name.Language.Code(2)</p></td><td><p>en</p></td><td><p>N</p></td><td><p>N</p></td></tr><tr><td><p>Name.Language.Code(3)</p></td><td><p>NULL</p></td><td><p>-</p></td><td><p>N</p></td></tr><tr><td><p>Name.Language.Code(4)</p></td><td><p>en-gb</p></td><td><p>Y</p></td><td><p>N</p></td></tr><tr><td><p>Name.Language.Code(5)</p></td><td><p>NULL</p></td><td><p>-</p></td><td><p>Y</p></td></tr></tbody></table>

<p>The above observations can be encoded by a single number called repetition level with the following rules:</p>
<ul>
<li><p>For a new record, use the number 0. The list is created implicitly as well.</p>
</li>
<li><p>For an existing record, if the list needs to be created, use the level at which the list is going to be created.</p>
</li>
<li><p>For an existing record, if the list does not need to be created, use the level at which the element is being added.</p>
</li>
</ul>
<p>Rewriting the table above with repetition level this time, we get for <code>Name.Language.Code</code>:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Value</td><td>RL</td></tr>
</thead>
<tbody>
<tr>
<td>en-US</td><td>0</td></tr>
<tr>
<td>en</td><td>2</td></tr>
<tr>
<td>NULL</td><td>1</td></tr>
<tr>
<td>en-gb</td><td>1</td></tr>
<tr>
<td>NULL</td><td>0</td></tr>
</tbody>
</table>
</div><h3 id="heading-definition-level">Definition level</h3>
<p>What is remaining is that for nested data, we need a way to represent optional values along the field path, particularly if it happened to be NULL.</p>
<p>If the data is present, this is the "normal" case. If it is not, then we need a way to tell where along the field path this data became NULL.</p>
<p>Putting it another way: Definition level is mostly useful when we have a) nested structure + b) optional fields that leads to NULL values. We will be able to recreate the missing pieces in a record if we know to be missing.</p>
<p>Let's take two examples this time. <code>Name.Language.Code</code> and <code>Name.Language.Country</code>. Here, <code>Code</code> is required but <code>Country</code> is optional. <code>Name</code> and <code>Language</code> are repeated groups which can be optional.</p>
<p>As before, for <code>Name.Language.Code</code> we have:</p>
<table><tbody><tr><td><p><strong>Field</strong></p></td><td><p><strong>Value</strong></p></td><td><p><strong>Upto which level optionals exist?</strong></p></td></tr><tr><td><p>Name.Language.Code(1)</p></td><td><p>en-US</p></td><td><p>2</p></td></tr><tr><td><p>Name.Language.Code(2)</p></td><td><p>en</p></td><td><p>2</p></td></tr><tr><td><p>Name.Language.Code(3)</p></td><td><p>NULL</p></td><td><p>1</p></td></tr><tr><td><p>Name.Language.Code(4)</p></td><td><p>en-gb</p></td><td><p>2</p></td></tr><tr><td><p>Name.Language.Code(5)</p></td><td><p>NULL</p></td><td><p>1</p></td></tr></tbody></table>

<p>For <code>Name.Language.Country</code> , we have:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Field</strong></td><td><strong>Value</strong></td><td><strong>Upto which level optionals exist?</strong></td></tr>
</thead>
<tbody>
<tr>
<td>Name.Language.Country(1)</td><td>us</td><td>3</td></tr>
<tr>
<td>Name.Language.Country(2)</td><td>NULL</td><td>2</td></tr>
<tr>
<td>Name.Language.Country(3)</td><td>NULL</td><td>1</td></tr>
<tr>
<td>Name.Language.Country(4)</td><td>gb</td><td>3</td></tr>
<tr>
<td>Name.Language.Country(5)</td><td>NULL</td><td>1</td></tr>
</tbody>
</table>
</div><p>Observe the slight difference between the two tables, and that is only because <code>Code</code> is a required field. And for a required field, the definition level does not apply, therefore the DL value is reduced slightly.</p>
<h3 id="heading-flattened-structure">Flattened structure</h3>
<p>The full flattened structure for all the fields can be found in Dremel's paper. For brevity, here are couple additional example for Links node, ie, <code>Links.Forward</code> and <code>Links.Backward</code>.</p>
<p><strong>Links.Forward</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Value</td><td>RL</td><td>DL</td></tr>
</thead>
<tbody>
<tr>
<td>20</td><td>0</td><td>2</td></tr>
<tr>
<td>40</td><td>1</td><td>2</td></tr>
<tr>
<td>60</td><td>1</td><td>2</td></tr>
<tr>
<td>80</td><td>0</td><td>2</td></tr>
</tbody>
</table>
</div><p><strong>Links.Backward</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Value</td><td>RL</td><td>DL</td></tr>
</thead>
<tbody>
<tr>
<td>NULL</td><td>0</td><td>1</td></tr>
<tr>
<td>10</td><td>0</td><td>2</td></tr>
<tr>
<td>30</td><td>1</td><td>2</td></tr>
</tbody>
</table>
</div><h2 id="heading-record-shredding">Record shredding</h2>
<p>With the key concepts above, we can look at the record shredding algorithm.</p>
<p>To recap, we want a way to convert nested data to flat data with additional metadata that helps preserve this structural information. Repetition levels and definition levels precisely do this.</p>
<p>First, lets referring to the Dremel's paper again for the pseudocode:</p>
<pre><code class="lang-plaintext">procedure DissectRecord(RecordDecoder decoder, FieldWriter writer, int repetitionLevel): 
    Add current repetitionLevel and definition level to writer
    seenFields = {} // empty set of integers
    while decoder has more field values
        FieldWriter chWriter = child of writer for field read by decoder
        int chRepetitionLevel = repetitionLevel

        if set seenFields contains field ID of chWriter
            chRepetitionLevel = tree depth of chWriter
        else
            Add field ID of chWriter to seenFields
        end if

        if chWriter corresponds to an atomic field
            Write value of current field read by decoder using chWriter at chRepetitionLevel
        else
            DissectRecord( new RecordDecoder for nested record read by decoder, chWriter, chRepetitionLevel)
        end if
    end while
end procedure
</code></pre>
<p>What we are basically trying to do is walk down the nested record recursively preserving the level information. Let's take a very high level view and break down the logic:</p>
<ul>
<li><p>RecordDecoder decodes binary records.</p>
</li>
<li><p>Each of the fields have a writer associated with them.</p>
</li>
<li><p>As we traverse down the tree, we maintain the repetition level and definition levels keeping in mind how they work from the previous sections.</p>
</li>
<li><p>If we encounter a node that does not have children (either NULL or some primitive type) we store the repetition level and definition level, otherwise we recurse further down.</p>
</li>
<li><p>Once we are done with the write procedure, the data is written to column chunks using the appropriate encoding mechanism.</p>
</li>
</ul>
<p>And that's it! In part 4, we will go over some of the code, classes involved in making this happen. If you want to check out the code right away, navigate to the <code>write</code> and <code>writeGroup</code> method of <code>GroupWriter.java</code> in parquet-hadoop module in the parquet-mr library. It provides an example reference implementation.</p>
<h2 id="heading-summary">Summary</h2>
<p>In this article, we looked at how parquet converts nested records into flat data with some additional metadata to preserve the nested structure. In the following articles, we will look at how to recreate the original record from this data and will take a peek under the hood in parquet-mr and spark codebase on how this happens as well.</p>
<h1 id="heading-references">References</h1>
<p>[1] Protobuf: <a target="_blank" href="https://developers.google.com/protocol-buffers/">https://developers.google.com/protocol-buffers/</a></p>
<p>[2] Apache Thrift: <a target="_blank" href="https://thrift.apache.org/">https://thrift.apache.org/</a></p>
]]></content:encoded></item><item><title><![CDATA[Parquet format - A deep dive : Part 1]]></title><description><![CDATA[The parquet file format is a well known data storage format that is famed for its "efficient storage" and "fast retrieval". I started this journey in an attempt to understand how spark and parquet work internally a bit better. It was a bit confusing ...]]></description><link>https://raghuveer.me/parquet-format-a-deep-dive-part-1</link><guid isPermaLink="true">https://raghuveer.me/parquet-format-a-deep-dive-part-1</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[Parquet]]></category><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Fri, 09 Dec 2022 12:38:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/fyeOxvYvIyY/upload/740cd1382423ecb68425019a32049432.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The parquet file format is a well known data storage format that is famed for its "efficient storage" and "fast retrieval". I started this journey in an attempt to understand how spark and parquet work internally a bit better. It was a bit confusing for me to go over the material and the code, so I thought of writing down my thoughts in hopes it would be of help to myself and any others too.</p>
<p>To begin, there were few objectives that I wanted to meet:</p>
<ol>
<li><p>What are the ideas / algorithms? that went into this data storage format?</p>
</li>
<li><p>How is data written / read?</p>
</li>
<li><p>Where is the codebase that puts it all together?</p>
</li>
</ol>
<p>This series of articles is the output of the above questions. This article presents an overview and the following articles go deeper into it, I hope you'll find it of some utility.</p>
<h1 id="heading-parquet-overview">Parquet overview</h1>
<h2 id="heading-how-is-data-stored">How is data <strong>stored</strong>?</h2>
<h3 id="heading-is-parquet-row-oriented-or-column-oriented-or-hybird">Is parquet row-oriented or column oriented or hybird?</h3>
<p>Broadly, there are three types of databases: Row-oriented, column-oriented and hybrid. There is good material on the web that explain them in greater detail, refer here<a target="_blank" href="https://levelup.gitconnected.com/data-storage-and-reporting-understanding-columna-vs-row-storage-formats-8cae46347fa3?gi=59f9c06236f7">[1]</a> and here<a target="_blank" href="https://www.youtube.com/watch?v=Vw1fCeD06YI">[2]</a>. Though parquet is typically called as a columnar store, in my opinion, it is not strictly columnar. It follows a hybrid approach. The similarity comes from that both of these approaches do read data a column at a time, however traditional columnar stores tend to store each attribute or column as files independently of other columns. Parquet instead stores columns together as "<strong>row groups</strong>". A diagram can help in understanding the differences a bit better.</p>
<p><strong>Row-oriented</strong></p>
<p>Columns in a record are arranged one after another, this allows you to seek to a particular record quickly through index structures but if you want to retrieve lots of specific column data quickly as is common in analytical workloads, then we would have to seek to the next record, take that column values, move to the next one and so on.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670411860241/m30_zmPsi.png" alt /></p>
<p><strong>Column-oriented</strong></p>
<p>The columns are stored together and typically stored independently of other column values. This allows for fast retrieval of vast amounts of data but tradeoffs advantages of row-oriented structures such as fast updates.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670411878782/prQ_hIisV.png" alt /></p>
<p><strong>Hybrid</strong></p>
<p>Hybrid does not have a strict definition as far as I can tell, it is implementation dependent. Parquet does it by grouping together column values like in the column oriented db, however they are not stored independently in files as it is traditionally done. Instead think of it as the columns of multiple records being packed together, so what we have is a group of records aka a "row group " with columns packed inside these row groups as chunks of data.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1670411890136/vg6IITb0U.png" alt /></p>
<h3 id="heading-physical-layout-and-term-definitions">Physical layout and term definitions</h3>
<p>The diagram for parquet's physical layout from <a target="_blank" href="https://parquet.apache.org/docs/file-format/">https://parquet.apache.org/docs/file-format/</a>.</p>
<p>![Parquet file format image](https://parquet.apache.org/images/FileLayout.gif align="middle")</p>
<p>Now for the terms used in describing parquet format. Most of the definitions listed here correspond to the image above and come from the parquet-format<a target="_blank" href="https://github.com/apache/parquet-format">[3]</a> library. There are a couple extra ones that come from code in parquet-hadoop module and parquet-column in parquet-mr<a target="_blank" href="https://github.com/apache/parquet-mr">[4]</a> which I thought to add in as well.</p>
<ul>
<li><p><strong>Column chunk</strong>: A row is a bunch of columns with each column having a value. A column chunk is a chunk of values for that particular column.</p>
</li>
<li><p><strong>Page / Data page</strong>: Column chunks are split into pages.</p>
</li>
<li><p><strong>Row group:</strong> A group of column chunks.</p>
</li>
<li><p><strong>Page header:</strong> Has metadata for this page. For example: number of values, repetition and definition levels which are used in the record shredding process, statistics such as min max which are useful in filtering, among others.</p>
</li>
<li><p><strong>Footer:</strong> Contains metadata. Notably, file metadata and block metadata.</p>
</li>
<li><p><strong>File metadata:</strong> Contains schema</p>
</li>
<li><p><strong>Block metadata:</strong> Contains row count, list of column chunk metadata, path to this column, etc.</p>
</li>
</ul>
<h3 id="heading-encoding-and-compression-schemes-used">Encoding and compression schemes used</h3>
<p>Parquet has a variety of encoding and compression schemes. Data is encoded using Run-Length encoding + bit packing hybrid. Another option that parquet can use for encoding is Delta encoding.</p>
<p>Parquet can store compressed data in its data pages. Snappy is the most popular compression scheme but parquet by default also supports gzip and zstd out of the box.</p>
<h2 id="heading-how-is-data-written">How is data <strong>written</strong>?</h2>
<h3 id="heading-record-shredding">Record shredding</h3>
<p>Sidebar: Whoever invented the term "record shredding" needs a raise, it sounds a lot cooler than record splitting.</p>
<p>We need a way to convert data stored as records into flat columnar data. And this data need not be flat, it can have structure. For example, if the data can be nested, can have array values, fields in records are optional and so on.</p>
<p>So how do we convert this into flat columnar data? This done using Dremel's record shredding algorithm<a target="_blank" href="https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36632.pdf">[5]</a>. The basic idea is that nested data is a tree of nodes at different levels, and what we do is associate each node with some metadata that lets us know if it is repeated, if the path to the node has NULL values and so on so that we do not lose the record structure information.</p>
<p>Writing data into parquet is documented in part 2 of this series.</p>
<h2 id="heading-how-is-data-read">How is data <strong>read</strong>?</h2>
<h3 id="heading-record-assembly">Record assembly</h3>
<p>Reading the data from parquet back into records requires a reconstruction of the original record structure. This is a little bit more complex than the record splitting process but at a high level it uses the metadata from before that we use to encode the record structure during the record shredding process. What we need is a way to arrange the nodes in relation to each other so that the original record can be reconstructed.</p>
<p>This process is documented in part 3 of this series.</p>
<h3 id="heading-reading-parquet-data-in-spark-vs-parquet-mr">Reading parquet data in spark vs. parquet-mr</h3>
<p>When it comes to implementation of reading parquet data, there are two libraries: the spark way and the parquet-mr way.</p>
<p>Parquet-mr itself has two APIs, an older api that used to read all the data, and filter records during assembly and a newer API that skips over pages by looking at the metadata in each block which is much faster. Spark uses a vectorized approach to read data, which means it reads columnar data in parquet files in batches for each column. It is based off parquet-mr's implementation to some extent. Spark has a fallback mechanism to parquet-mr's code which can be set in the spark configuration. There are other tricks which are used to improve reading speed: Projection pushdown, predicate pushdown, bloom filters among others.</p>
<h2 id="heading-grokking-the-source-code">Grokking the source code</h2>
<p>In part 4, we will look at some of the key classes, methods used in spark and parquet-mr along with the code flow. This is useful when you want to navigate the code base.</p>
<h1 id="heading-references">References</h1>
<p>[1] <a target="_blank" href="https://levelup.gitconnected.com/data-storage-and-reporting-understanding-columna-vs-row-storage-formats-8cae46347fa3?gi=59f9c06236f7">https://levelup.gitconnected.com/data-storage-and-reporting-understanding-columna-vs-row-storage-formats-8cae46347fa3?gi=59f9c06236f7</a></p>
<p>[2] <a target="_blank" href="https://www.youtube.com/watch?v=Vw1fCeD06YI">https://www.youtube.com/watch?v=Vw1fCeD06YI</a></p>
<p>[3] <a target="_blank" href="https://github.com/apache/parquet-format">https://github.com/apache/parquet-format</a></p>
<p>[4] <a target="_blank" href="https://github.com/apache/parquet-mr">https://github.com/apache/parquet-mr</a></p>
<p>[5] <a target="_blank" href="https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36632.pdf">https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36632.pdf</a></p>
]]></content:encoded></item><item><title><![CDATA[The first step]]></title><description><![CDATA[Why do this?
Several reasons, some practical and others personal. The more pratical reasons revolve around why having a blog for software engineers might be a good idea. The "ghost who codes" is a nice take on this line of reasoning. The other one is...]]></description><link>https://raghuveer.me/the-first-step</link><guid isPermaLink="true">https://raghuveer.me/the-first-step</guid><dc:creator><![CDATA[Raghuveer Sriraman]]></dc:creator><pubDate>Wed, 16 Nov 2022 23:32:14 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-why-do-this">Why do this?</h2>
<p>Several reasons, some practical and others personal. The more pratical reasons revolve around why having a blog for software engineers might be a good idea. The <a target="_blank" href="https://www.troyhunt.com/the-ghost-who-codes-how-anonymity-is/">"ghost who codes"</a> is a nice take on this line of reasoning. The other one is that it is quite useful to maintain a "commit log" of your thoughts if you will, and to be able to refer back to it if needed. Other practical reasons I've found center around the benefits of journalling. Though I think this blog will probably be technical in nature, I have a feeling forcing your thoughts into the written form will encourage the ability to communicate better. And good communication, I believe, is one of the most important skills to have for an engineer working in a team.</p>
<p>As for personal reasons, being able to share knowledge and know that it could perhaps be useful to others is a great feeling. I've been fortunate to do this in a professional capacity once before at <a target="_blank" href="https://crio.do">Crio.do</a> and certainly treasure those memories. And lastly, well to put it bluntly, writing a public blog is uncomfortable territory for me. This is my fourth attempt at trying to start a blog, the other attempts having fallen to "char log kya kahenge" syndrome. Every time I've had the feeling of wanting to publcily share something cool that I've come across or done, inevitably I would find myself convincing me out of it. Heck, as I'm writing this I'm imagining deleting everything and going back to youtube xD Note to future me - don't delete this blog!</p>
<p>Well, with that out of the way, let's dive into it. The first post is about the parquet format. I hope you find it useful.</p>
]]></content:encoded></item></channel></rss>