<?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[kurdapyo does data]]></title><description><![CDATA[kurdapyo does data]]></description><link>https://data.kurdapyo.com</link><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Oct 2026 14:56:58 GMT</lastBuildDate><atom:link href="https://data.kurdapyo.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[MINUS the Pain: Regression Testing at Scale]]></title><description><![CDATA[A decade ago, I was fortunate enough to work on a regression testing suite for a critical banking system. The technology stack was a greatest hits collection from another era: Oracle, SQL, PL/SQL, Per]]></description><link>https://data.kurdapyo.com/minus-the-pain-regression-testing-at-scale</link><guid isPermaLink="true">https://data.kurdapyo.com/minus-the-pain-regression-testing-at-scale</guid><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Sun, 16 Aug 2026 08:36:19 GMT</pubDate><content:encoded><![CDATA[<p>A decade ago, I was fortunate enough to work on a regression testing suite for a critical banking system. The technology stack was a greatest hits collection from another era: Oracle, SQL, PL/SQL, Perl and AutoSys.</p>
<p>The goal was straightforward: run the same full batch through two identical, production-like environments using identical inputs, compare every output, and ensure the batch completed without errors. This was parallel testing at scale. Parallel testing is the approach; regression testing is the purpose. And to put the scale into perspective, the batch consisted of around 3,000 individual jobs and produced roughly 1,000 extracts.</p>
<p>Back then, we solved the comparison problem with dynamically generated SQL, using row count comparisons and two-way <code>MINUS</code> queries to identify differences between the outputs.</p>
<div>
<div>💡</div>
<div>A two-way <code>MINUS</code> comparison runs <code>A MINUS B</code> and <code>B MINUS A</code> to identify rows that exist in one dataset but not the other.</div>
</div>

<p>We then built a reporting layer to turn those differences into something people could actually read and investigate. It worked, but it wasn't exactly elegant. You had to build the SQL, handle different tables and keys, interpret the results, and deal with all the edge cases that come with comparing datasets.</p>
<p>Fast forward a decade, and I still find myself solving similar problems in the Databricks world. This time, instead of wrangling SQL statements and building our own comparison reports, I reached for <a href="https://github.com/capitalone/datacompy"><strong>DataComPy</strong></a>.</p>
<h2>DataComPy</h2>
<p>DataComPy is a Python package for comparing datasets. Originally created for pandas, it also supports Spark DataFrames, making it a natural fit for Databricks.</p>
<p>Using it is quite simple. Set up two DataFrames, specify the join columns, then run the report.</p>
<h3>Basic Example</h3>
<p>Let's say we have the same <code>account</code> output generated in two environments and want to verify that they are identical.</p>
<pre><code class="language-python"># SparkSQLCompare
env01_df = spark.table("env01.account")
env02_df = spark.table("env02.account")

compare = SparkSQLCompare(
    spark,
    env01_df,
    env02_df,
    join_columns='acct_id',
    df1_name='env01',  # friendly name on report
    df2_name='env02',  # friendly name on report
)
compare.matches()

print(compare.report())
</code></pre>
<p><code>matches()</code> gives you a quick boolean answer, while <code>report()</code> provides the detailed explanation of where the differences are.</p>
<h3>The report</h3>
<p>This is where DataComPy starts to feel familiar to me. In the old system, we had to build our own reporting layer to turn raw comparison results into something investigators could work with. We also had to compare row counts because <code>MINUS</code> doesn't account for duplicates.</p>
<p>DataComPy gives you much of that out of the box.</p>
<pre><code class="language-python">DataFrame Summary
-----------------
  DataFrame  Columns  Rows
0  env01        5     5
1  env02        4     5

Column Summary
--------------
Number of columns in common: 4
Number of columns in env01 but not in env02: 1
Number of columns in env02 but not in env01: 0

Row Summary
-----------
Matched on: id
Any duplicates on match values: No
Absolute Tolerance: 0
Relative Tolerance: 0
Number of rows in common: 4
Number of rows in env01 but not in env02: 1
Number of rows in env02 but not in env01: 1

Number of rows with some compared columns unequal: 4
Number of rows with all compared columns equal: 0

Column Comparison
-----------------
Number of columns compared with some values unequal: 3
Number of columns compared with all values equal: 1
Total number of values which compare unequal: 6
</code></pre>
<h3>Inspecting the data</h3>
<p>The report tells you <strong>that</strong> something is different. The next question is <strong>what exactly is different?</strong></p>
<p>This is where the other attributes and methods of the <code>SparkSQLCompare</code> class come in handy. It exposes DataFrames that are useful for investigating the differences. In Databricks, these can be displayed using the notebook's <code>display()</code> function, which makes the investigation much easier.</p>
<ul>
<li><code>df1_unq_rows</code> and <code>df2_unq_rows</code> let me inspect rows that exist in only one of the DataFrames.</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/683c1fb03d411e38dcd9bc70/3a5e555f-5fec-49c3-aee4-f3bca8db0b09.png" alt="" style="display:block;margin:0 auto" />

<ul>
<li><code>all_mismatch()</code> gives me a DataFrame containing the join key and the columns values for each DataFrame.</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/683c1fb03d411e38dcd9bc70/0c181802-b4aa-4c52-8a96-4d9945abdc60.png" alt="" style="display:block;margin:0 auto" />

<h2>The hard yards</h2>
<p>DataComPy makes the comparison part surprisingly easy. But anyone who has built a serious regression-testing framework knows that <strong>comparison is only one piece of the puzzle</strong>.</p>
<p>The real work is in:</p>
<ul>
<li><p>Setting up the parallel environments, inputs and automated output collection</p>
</li>
<li><p>Investigating differences</p>
</li>
<li><p>Reducing noise</p>
</li>
</ul>
<p>I remember the first few iterations of our regression testing. The reports were full of differences. It took weeks to work through them.</p>
<p>The challenge wasn't simply finding differences. It was determining which differences mattered.</p>
<p><strong>A difference is not necessarily a defect.</strong></p>
<p>Some were genuine defects. Others were noise:</p>
<ul>
<li><p>Batch timestamps</p>
</li>
<li><p>Run-specific identifiers</p>
</li>
<li><p>Control columns</p>
</li>
<li><p>Null vs default values</p>
</li>
<li><p>Schema or datatype differences</p>
</li>
<li><p>Floating-point and rounding differences</p>
</li>
<li><p>Non-deterministic processing</p>
</li>
<li><p>Differences caused by jobs running in a different sequence</p>
</li>
<li><p>Legitimate differences introduced by an intentional change</p>
</li>
</ul>
<p>The goal wasn't to get the number of differences to zero. It was to get the number of <strong>unexpected differences</strong> to zero.</p>
<p>As the process became more automated and routine, we started to see the payoff. We saw fewer unexpected defects make it into production. Developers spent less time diagnosing problems after deployment, and significant migrations and platform changes could be made with much greater confidence.</p>
<p>Regression testing became less of a safety net we hoped we wouldn't need and more of an enabler for making changes safely.</p>
<h2>Why I'm at it again</h2>
<p>The temptation with regression testing is to see it as another testing framework to build, another pipeline to maintain, and another set of reports for developers to work through.</p>
<p>And honestly, getting it started can feel like a lot of work.</p>
<p>But once you have it running, something interesting happens.</p>
<p><strong>You stop being afraid of making big changes.</strong></p>
<p>Want to upgrade the Databricks runtime? Compare it.</p>
<p>Want to refactor a complicated pipeline? Compare it.</p>
<p>Want to migrate workloads or introduce a performance optimisation? Compare them.</p>
<p>Run the same inputs through the old and new implementations. Reconcile the outputs, investigate the differences and reduce the noise. What you end up with is much more valuable than a collection of unit tests.</p>
<p>You have <strong>evidence</strong>.</p>
<p>Evidence that the new implementation behaves like the old one. Evidence that a migration hasn't silently changed the numbers. Evidence that a performance improvement hasn't introduced a subtle business logic regression.</p>
<p>That's the real value of parallel testing.</p>
<p>It's not about proving that two systems are identical. They're often not supposed to be. It's about making the differences <strong>visible, explainable and intentional</strong>.</p>
<p>The investment is not in building another testing framework.</p>
<p><strong>The investment is in buying your team the confidence to change things.</strong></p>
<p>And for systems that matter, that can be worth a lot.</p>
]]></content:encoded></item><item><title><![CDATA[Elements of Style and Substance: Databricks Edition]]></title><description><![CDATA[Databricks development has accelerated significantly in our organisation. Engineers from a wide range of backgrounds are now contributing code—often with AI assistance—to solve an equally wide range o]]></description><link>https://data.kurdapyo.com/elements-of-style-and-substance-databricks-edition</link><guid isPermaLink="true">https://data.kurdapyo.com/elements-of-style-and-substance-databricks-edition</guid><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Sat, 27 Jun 2026 11:48:02 GMT</pubDate><content:encoded><![CDATA[<p>Databricks development has accelerated significantly in our organisation. Engineers from a wide range of backgrounds are now contributing code—often with AI assistance—to solve an equally wide range of business problems.</p>
<p>As adoption has grown, we've started to see clear patterns in what leads to maintainable, supportable production code, and what tends to create operational headaches.</p>
<p>While Databricks notebooks are a long way from spacecraft flight software, I found myself repeatedly returning to NASA's <em>Power of 10 Rules for Developing Safety Critical Code</em>. The principles are surprisingly transferable: write code that is easy to understand, easy to test, and easy to troubleshoot under pressure.</p>
<p>These are the guidelines I use when reviewing pull requests. If I wouldn't want to debug it let alone make sense of it at 3AM during a production incident, it probably needs another pass.</p>
<h2>Notebook etiquette</h2>
<p>Notebooks remain controversial in some engineering circles, particularly among developers who prefer packaged Python applications. In an ideal world, every production workload would live in a well-structured Python package with proper testing and deployment pipelines. This is actually Rule #10 which starts with:</p>
<blockquote>
<p>All code must be compiled, from the first day of development, with all compiler warnings enabled at the most pedantic setting available. All code must compile without warnings. All code must also be checked daily with at least one, but preferably more than one, strong static source code analyzer and should pass all analyses with zero warnings.</p>
</blockquote>
<p>However, our reality is different. Many teams use notebooks because they allow rapid delivery and lower operational overhead. If notebooks are going to be part of the production platform, they should be treated as production code.</p>
<p>Use the notebook structure intentionally:</p>
<ul>
<li><p>Divide code into logical sections.</p>
</li>
<li><p>Give cells meaningful titles.</p>
</li>
<li><p>Use markdown to explain non-obvious business logic.</p>
</li>
<li><p>Keep setup, transformation, validation, and output stages clearly separated.</p>
</li>
</ul>
<h3>Simple Flow</h3>
<blockquote>
<p>Restrict all code to very simple control flow constructs—do not use goto statements, setjmp or longjmp constructs, or direct or indirect recursion.</p>
</blockquote>
<p>This was NASA's first rule. Though these goto statements don't really apply, the message is still simple and applicable. The procedural flow should be simple to understand.</p>
<p>Deeply nested logic is often a sign that responsibilities are mixed together. Instead of adding another level of indentation, consider:</p>
<ul>
<li><p>Extracting logic into a helper function.</p>
</li>
<li><p>Performing validation up front and exiting early.</p>
</li>
<li><p>Splitting complex transformations into separate stages.</p>
</li>
</ul>
<p>A notebook should read like a pipeline, not a maze.</p>
<h3>Function Construction</h3>
<p>The Don't Repeat Yourself(DRY) principle is well known and I've seen many functions or procedures done which scare me. The common theme for these are they try to do too much. They don't follow the 4th NASA rule, which states:</p>
<blockquote>
<p>No function should be longer than what can be printed on a single sheet of paper in a standard format with one line per statement and one line per declaration. Typically, this means no more than about 60 lines of code per function.</p>
</blockquote>
<p>This is strict but brilliant. Large functions are particularly problematic in Spark workloads because they often combine business logic, data transformation, validation, logging, and persistence into a single block of code.</p>
<p>Prefer small functions with a single responsibility. A reader should be able to understand what a function does without scrolling through multiple screens of code.</p>
<h3>Error Handling</h3>
<p>Catch only exceptions that you understand and can meaningfully recover from.</p>
<p>Avoid patterns such as:</p>
<pre><code class="language-python">try:
    do_work()
except Exception as e:
    pass
</code></pre>
<p>Or worse:</p>
<pre><code class="language-python">try:
    do_work()
except Exception as e:
    logger.error(e)
</code></pre>
<p>These patterns hide failures and make troubleshooting significantly harder.</p>
<p>Unexpected exceptions should generally be allowed to fail the workflow so monitoring, retry policies, and alerting mechanisms can do their job.</p>
<p>If an exception is handled, there should be a clear business or operational reason for doing so.</p>
<p>This is a common anti-pattern in Databricks projects and worth calling out explicitly.</p>
<h3>Idempotency</h3>
<p>Idempotency is one of those concepts every data engineer agrees is important, yet it is often only applied to individual Spark operations rather than the workflow as a whole.</p>
<p>Many pipelines are built with the assumption that they will run once, succeed once, and never need to be revisited. Reality is messier. Clusters terminate unexpectedly, upstream systems resend data, workflows are retried automatically, and operators rerun jobs manually during incidents.</p>
<p>A well-designed workflow should be able to fail halfway through, be rerun, and still produce the same end result.</p>
<p>When building a workflow, ask yourself:</p>
<ul>
<li><p>Can this notebook be executed multiple times without creating duplicates?</p>
</li>
<li><p>What happens if the workflow fails after writing some, but not all, of its outputs?</p>
</li>
<li><p>What happens if an upstream job retries and sends the same data again?</p>
</li>
<li><p>Can an operator safely rerun the workflow during an incident without needing manual cleanup?</p>
</li>
</ul>
<p>Delta Lake gives us powerful tools such as MERGE, transactional writes, and change data capture patterns, but those features alone do not make a workflow idempotent. Idempotency is a design principle, not a technology feature.</p>
<p>Design for retries from the start. Assume failures will happen. The goal is not to prevent every failure, but to make recovery predictable and safe.</p>
<h3>Final Thoughts</h3>
<p>None of these guidelines are particularly revolutionary. Most are simply attempts to keep code understandable when the original author is unavailable, the pressure is high, and a production issue needs to be resolved quickly.</p>
<p>The best Databricks code is rarely the cleverest. It is the code that another engineer can read, understand, and safely modify months later with minimal context.</p>
<p>When reviewing a pull request, I often ask myself a simple question:</p>
<blockquote>
<p>Would I be comfortable when I'm tired, sleepy, or maybe even a little hung over to troubleshoot this code?</p>
</blockquote>
<p>If the answer is no, the code probably needs another pass.</p>
<p>Write notebooks that are easy to follow. Keep functions focused. Handle errors deliberately. Design for retries. Optimise for the engineer who has to support the workload, not the engineer writing it.</p>
<p>That engineer may very well be you.</p>
]]></content:encoded></item><item><title><![CDATA[Databricks can have bad plans too]]></title><description><![CDATA[Lately we’ve been tuning and refactoring several Databricks workflows. Nothing radical — mostly sensible engineering improvements:

replacing count-based existence checks with is_empty

swapping singl]]></description><link>https://data.kurdapyo.com/databricks-can-have-bad-plans-too</link><guid isPermaLink="true">https://data.kurdapyo.com/databricks-can-have-bad-plans-too</guid><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Fri, 29 May 2026 11:42:39 GMT</pubDate><content:encoded><![CDATA[<p>Lately we’ve been tuning and refactoring several Databricks workflows. Nothing radical — mostly sensible engineering improvements:</p>
<ul>
<li><p>replacing count-based existence checks with <code>is_empty</code></p>
</li>
<li><p>swapping single-task loops for <code>foreach</code> tasks</p>
</li>
<li><p>upgrading Databricks runtimes</p>
</li>
</ul>
<p>The code is shared across five datasets, so we expected broad performance gains.</p>
<p>During testing, everything looked promising. Cluster runtimes dropped, DBU usage improved, and the jobs consistently finished faster.</p>
<p>After a long regression-testing cycle, we released to production.</p>
<p>Four of the five datasets behaved exactly as expected: runtimes dropped from roughly an hour down to 20–30 minutes.</p>
<p>Then the fifth dataset did the opposite.</p>
<p>A workflow that normally completed in ~45 minutes suddenly started taking over an hour.</p>
<h2>Initial Investigations</h2>
<p>Looking at the <code>foreach</code> task, two iterations stood out immediately. While most iterations completed in under five minutes, these two were each taking close to an hour.</p>
<img src="https://cdn.hashnode.com/uploads/covers/683c1fb03d411e38dcd9bc70/621b2d5b-1b77-4ef8-968a-2145015da5a0.png" alt="" style="display:block;margin:0 auto" />

<p>The odd part was that they didn’t correspond to unusually large partitions or datasets — at least not based on the metrics we initially checked.</p>
<p>Digging deeper into the slow iterations, we found two non-lazy operations taking significant runtimes:</p>
<ul>
<li><p>a query retrieving the max timestamp</p>
</li>
<li><p>an <code>is_empty</code> existence check</p>
</li>
</ul>
<p>Both queries were taking around 15 minutes each.</p>
<p>That was unexpected, especially given how lightweight those operations normally are.</p>
<h2>The not so successful attempts at tuning</h2>
<p>We tried several tuning approaches that seemed likely to help:</p>
<ul>
<li><p>Changed the sequence of the <code>foreach</code> iterations so tasks wouldn't run concurrently and contend for resources.</p>
</li>
<li><p>Bumped up driver/executor resources to see if it was resource starvation.</p>
</li>
<li><p>Tweaked Spark parallelism and shuffle partitioning.</p>
</li>
<li><p>Looked for skew and tried redistributing keys.</p>
</li>
<li><p>Considered setting the concurrency to 1 but decided it might make the job run for 2-3 hours.</p>
</li>
<li><p>Considered gathering stats but saw the full stats were already there.</p>
</li>
<li><p>Enabled (and disabled) a few runtime optimizations.</p>
</li>
</ul>
<p>Those only cut off less then 5 minutes. Not enough. How can the serialized operation run faster than the foreach task in this scenario. The problem remained stubbornly intermittent.</p>
<h2>What finally helped (the fix)</h2>
<p>The problem kept bothering me over the weekend. It started to feel very similar to the old days of wrestling with the Oracle optimizer and comparing execution plans.</p>
<p>Back on Monday, I dug deeper into the Spark UI, looking for the SQL DataFrames behind the slow queries.</p>
<p>Once I found them, I used AI to help translate the Spark execution plans into something closer to the Oracle-style plans I was more familiar with reading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/683c1fb03d411e38dcd9bc70/b9214373-83c3-4319-a07c-13e4a1ecd703.png" alt="" style="display:block;margin:0 auto" />

<p>It looked like a subtle optimizer change, but it had a massive impact on those two queries under concurrency.</p>
<p>That narrowed the investigation to one major variable: the DBR upgrade from 15.4 to 16.4.</p>
<p>After validating the hypothesis, we rolled the runtime back to 15.4.</p>
<p>The result was immediate:</p>
<ul>
<li><p>total job runtime dropped to 11 minutes</p>
</li>
<li><p>the two problematic iterations fell from over an hour to roughly two minutes</p>
</li>
</ul>
<p>Interestingly, the refactor itself wasn’t the real problem. The new concurrency model simply exposed an optimizer behaviour change that had never surfaced in the serialized implementation.</p>
<h2>Takeaways</h2>
<p>After solving that, we could finally celebrate. Four datasets got dramatically faster, while the fifth reminded us that performance tuning is rarely as simple as “newer runtime = better runtime.”</p>
<p>A few things stood out from this exercise:</p>
<ul>
<li><p><strong>Runtime upgrades deserve the same scrutiny as application changes.</strong><br />We treated the DBR upgrade as a low-risk improvement bundled into a broader refactor. In reality, it introduced different optimizer behaviour that caused a major regression for a specific workload.</p>
<p>Runtime upgrades aren’t just infrastructure changes — they can alter execution plans, heuristics, and query strategies in ways that materially affect performance. They deserve the same level of regression and performance testing as application code changes.</p>
</li>
<li><p><strong>Execution plans still matter.</strong><br />Even in managed platforms like Databricks, understanding what the engine is actually doing remains critical.</p>
<p>The breakthrough only came after digging through the Spark UI and comparing execution plans closely enough to notice that the faster plan was using a bloom filter while the slower one was not.</p>
<p>The tooling may have changed, but the engineering skill of reading and reasoning about execution plans is still incredibly valuable.</p>
</li>
<li><p><strong>Parallelism can expose problems that serialized workloads hide.</strong><br />The foreach implementation surfaced behaviour that never appeared in the single-task version. Concurrency, optimizer choices, and runtime heuristics can interact in unexpected ways, particularly at production scale.</p>
</li>
<li><p><strong>Non-production environments rarely behave exactly like production.</strong><br />We never reproduced the slowdown outside production, despite extensive testing.</p>
<p>The issue only surfaced under production concurrency and workload characteristics, which exposed gaps in our validation approach. Dataset shape, partition distribution, cluster sizing, and concurrent execution patterns all matter when testing performance-sensitive changes.</p>
</li>
<li><p><strong>Observability pays for itself during incidents.</strong><br />Better instrumentation would have shortened this investigation significantly.</p>
<p>Faster access to execution plans, clearer query-level metrics, better task-level visibility, and more targeted logging would have reduced a lot of the guesswork and helped isolate the regression earlier.</p>
<p>Performance tuning becomes dramatically easier when the system makes its decisions observable.</p>
</li>
</ul>
<p>The silver lining is that these are the kinds of problems that sharpen engineering instincts. There’s something deeply satisfying about tracking down a subtle optimizer regression — especially when the final fix turns an hour-long task back into a two-minute operation.</p>
<h2>Addendum</h2>
<p>After the typical back-and-forth shuffle with Databricks Support, they identified a new Spark planning optimisation in DBR 16.4 called <code>CollapsePartialAndFinalAggregates</code> as the root cause of the plan change. This feature is enabled by default in this version. To disable it, set the Spark configuration <code>spark.databricks.execution.collapsePartialAndFinalAggregates.enabled</code> to false.</p>
]]></content:encoded></item><item><title><![CDATA[Databricks Antipatterns: Two Hard Lessons from the Field]]></title><description><![CDATA[Experience is a great teacher. After several years working with Databricks, I’ve noticed patterns that made perfect sense at the start but eventually became costly. At small scale they work fine — we ]]></description><link>https://data.kurdapyo.com/databricks-antipatterns-two-hard-lessons-from-the-field</link><guid isPermaLink="true">https://data.kurdapyo.com/databricks-antipatterns-two-hard-lessons-from-the-field</guid><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Fri, 15 May 2026 10:37:48 GMT</pubDate><content:encoded><![CDATA[<p>Experience is a great teacher. After several years working with Databricks, I’ve noticed patterns that made perfect sense at the start but eventually became costly. At small scale they work fine — we get away with them for months or even years — until an edge case hits or volumes grow beyond what we expected. The event we joked was rarer than winning the Powerball happens, or that hypothetical load we thought we'd never reach becomes reality.</p>
<p>This is 2 of those hard-earned lessons: antipatterns I now watch for and the guidance I share when I see them creeping into projects.</p>
<h2>Single Row DMLs</h2>
<p>Many engineers coming from a stored-procedure or OLTP background (I was one of them) naturally reach for single-row INSERT/UPDATE/DELETE operations: write a checkpoint row, flip a flag, append a log entry — all from the same SQL client used for development. That feels simple and fast during development, and it usually “just works” at low scale.</p>
<p>Why this becomes a problem on Delta</p>
<ul>
<li><p>Every individual DML tends to produce a tiny file. Tens, then thousands, then millions of those files drive:</p>
<ul>
<li><p>poor read performance (many small files = high task scheduling and IO overhead),</p>
</li>
<li><p>long metadata operations (Delta/Apache Spark must track many files and transactions),</p>
</li>
<li><p>higher storage and management costs,</p>
</li>
<li><p>increased maintenance (VACUUM, compaction, and longer checkpoint/commit times).</p>
</li>
</ul>
</li>
<li><p>Single-row DMLs also make compaction and concurrency trickier — your housekeeping jobs have to work harder and run more frequently.</p>
</li>
</ul>
<p>Practical guidance and alternatives</p>
<ul>
<li><p>Avoid single-row transactional writes to Delta tables whenever possible.</p>
</li>
<li><p>Prefer append-only, batched writes:</p>
<ul>
<li><p>accumulate events/changes and write them in bulk (micro-batches) rather than per row,</p>
</li>
<li><p>use a streaming approach (Structured Streaming) with appropriate micro-batch sizes and checkpointing for incremental, bounded writes.</p>
</li>
</ul>
</li>
<li><p>For upserts/merges, use MERGE with a source batch (not one-row MERGEs). Build a small staging dataset of changes and MERGE that in one operation.</p>
</li>
<li><p>Control file count at write time:</p>
<ul>
<li><p>repartition/coalesce to a reasonable number of output files before writing,</p>
</li>
<li><p>avoid writing one tiny file per write operation.</p>
</li>
</ul>
</li>
<li><p>If you must persist fine-grained events, write them to append-only files (Delta or Parquet) and compact periodically.</p>
</li>
<li><p>Use Delta compaction tools on a schedule (OPTIMIZE/compaction jobs) to consolidate small files, and monitor file counts so compaction is run before it becomes painful.</p>
</li>
<li><p>Prefer external logging/monitoring for operational logs: cluster logs, Databricks/job metrics, or a centralized logging system (e.g., ELK, Splunk, cloud logging). Application-level logs in Delta are only justified if you need them for audit or replay — and then treat them as batched event streams, not single-row table DMLs.</p>
</li>
</ul>
<p>When you can’t avoid single-row operations</p>
<ul>
<li><p>If strict transactional single-row updates are unavoidable, isolate that usage:</p>
<ul>
<li><p>put such records in a dedicated table with a clear compaction policy,</p>
</li>
<li><p>limit retention or TTL to keep the table small,</p>
</li>
<li><p>schedule frequent, automated compaction and vacuuming,</p>
</li>
<li><p>and instrument monitoring to alert when small-file counts grow.</p>
</li>
</ul>
</li>
</ul>
<p>Bottom line Single-row DMLs are easy and familiar, but they don’t scale well on Delta. Design for batched or append-only writes, use MERGE for bulk upserts, and rely on compaction/OPTIMIZE and centralized logging when possible to avoid the small-files and metadata headaches.</p>
<h2>dbutils.fs — convenience vs. scalability</h2>
<p>dbutils.fs is incredibly handy in notebooks: quick file reads/writes, directory listings, tiny helper copies and deletes. That convenience makes it an easy go-to during development — but several common uses become antipatterns in production.</p>
<p>Common antipatterns</p>
<ul>
<li><p>Using <code>dbutils.fs.put</code> or repeated <code>dbutils.fs.cp</code>/<code>mv</code>/<code>rm</code> in tight loops to emit many small files (e.g., one file per event or checkpoint).</p>
</li>
<li><p>Relying on file presence / <code>_SUCCESS</code> files as locks or transactional signals (race conditions and non-atomic checks).</p>
</li>
<li><p>Using <a href="http://dbutils.fs.ls"><code>dbutils.fs.ls</code></a> to enumerate very large directories from notebooks (slow, paginated REST calls).</p>
</li>
<li><p>Using dbutils for large data movement (instead of Spark, cloud SDKs or storage-native tools).</p>
</li>
<li><p>Storing secrets/credentials in files written via dbutils or using it to persist sensitive info.</p>
</li>
<li><p>Orchestrating business logic by chaining many dbutils operations synchronously (blocking, brittle).</p>
</li>
<li><p>Using dbutils.fs for compaction or heavy maintenance in production without retry/monitoring.</p>
</li>
</ul>
<p>Why these are a problem</p>
<ul>
<li><p>Many dbutils.fs calls translate to REST operations against cloud storage; they’re relatively slow and expensive when done at scale.</p>
</li>
<li><p>They don’t provide the transactional/atomic semantics Delta or Spark offer — leads to races and inconsistent state.</p>
</li>
<li><p>Repeated small-file writes create the same small-files and metadata explosion problems described earlier.</p>
</li>
<li><p>Lack of retries, backoff, and backpressure patterns in ad-hoc uses makes jobs brittle and hard to debug.</p>
</li>
</ul>
<p>Practical guidance and alternatives</p>
<ul>
<li><p>For data writes, prefer Spark/DataFrame APIs or Delta write APIs:</p>
<ul>
<li>Use DataFrame.write.format("delta").mode("append").save(path) or Structured Streaming with controlled micro-batch sizing instead of <code>dbutils.fs.put</code>.</li>
</ul>
</li>
<li><p>For bulk copy/move of large datasets, prefer:</p>
<ul>
<li>Spark read/write (distributed), cloud storage SDKs (AWS S3 SDK, Azure Data Lake SDK) or storage-native copy tools — not <code>dbutils.fs.cp</code> for terabytes of data.</li>
</ul>
</li>
<li><p>For listing/metadata at scale, prefer:</p>
<ul>
<li>Use Spark’s distributed listing (spark.read or cloud SDK with pagination) or storage-native APIs that support efficient pagination.</li>
</ul>
</li>
<li><p>For coordination and locking, prefer:</p>
<ul>
<li>Use Delta transactions, a small metadata table (Delta) for control records, or an external lock service (Azure Blob lease, S3 atomic rename patterns, Redis, etc.), not file-existence checks.</li>
</ul>
</li>
<li><p>For event/app-level logs, write append-only files in batches and compact (OPTIMIZE) periodically; avoid single-row file writes.</p>
</li>
<li><p>For secrets, use secret scopes / cloud KMS; never write credentials with dbutils.fs to storage.</p>
</li>
<li><p>Add robust retry/backoff, idempotency, and alerting around any required dbutils.fs calls to handle transient errors.</p>
</li>
</ul>
<p>Safe uses of dbutils.fs</p>
<ul>
<li><p>Quick inspections in interactive sessions (<a href="http://dbutils.fs.ls"><code>dbutils.fs.ls</code></a> of small directories, <code>head</code> for a sample).</p>
</li>
<li><p>Small administrative tasks in low-volume jobs, with clear TTL and compaction policy.</p>
</li>
<li><p>One-off notebook utilities, not core production pipelines.</p>
</li>
</ul>
<p>Operational checklist</p>
<ul>
<li><p>Audit notebook/job code for dbutils.fs usage in loops or per-row logic.</p>
</li>
<li><p>Replace per-row file writes with batched writes or Delta upserts.</p>
</li>
<li><p>Add metrics: file count, average file size, listing latencies; alert when thresholds exceeded.</p>
</li>
<li><p>Schedule compaction/OPTIMIZE jobs before file counts get out of hand.</p>
</li>
<li><p>Use cloud-native locking or Delta where atomicity is required.</p>
</li>
</ul>
<p>Bottom line dbutils.fs is great for development and light administrative tasks, but it’s not a substitute for distributed, transactional, or high-throughput I/O patterns. Replace repetitive or large-scale dbutils.fs usage with Spark/Delta writes, cloud storage SDKs, or dedicated coordination mechanisms — and treat any remaining dbutils operations as small, monitored, and idempotent utilities.</p>
<h2>Closing thoughts</h2>
<p>Experience taught me that solutions that feel “simple and fast” in development often turn expensive at scale. Small decisions—like doing one-row DMLs from a notebook—compound into operational debt: slower queries, heavier maintenance, and surprising costs. The best defense is awareness plus a few engineering guardrails: batch where possible, use the platform’s compaction and write-optimization features, and add monitoring to catch small-file growth early. Those moves keep your Delta tables healthy and let you scale from a prototype to production without painful rewrites.</p>
]]></content:encoded></item><item><title><![CDATA[Kurdapyo's Newbie Guide to PySpark]]></title><description><![CDATA[Lately, I've been working for an enterprise customer with hundreds of data engineers in a chapter. They're new in their Databricks journey, and their language of choice is SQL with a sprinkling of ora]]></description><link>https://data.kurdapyo.com/kurdapyo-s-newbie-guide-to-pyspark</link><guid isPermaLink="true">https://data.kurdapyo.com/kurdapyo-s-newbie-guide-to-pyspark</guid><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Tue, 05 May 2026 09:48:19 GMT</pubDate><content:encoded><![CDATA[<p>Lately, I've been working for an enterprise customer with hundreds of data engineers in a chapter. They're new in their Databricks journey, and their language of choice is SQL with a sprinkling of oracle PL/SQL, redshift stored procedures, some shell scripting, some panda-esq python code thrown in the mix. Having done more Databricks work more than the average data engineer, little old rebellious me has been asked to come out with some standards on top of my regular programming. I couldn't get to the task immediately, and ended up in some meetings to review initial coding attempts by our data engineers. Some patterns emerged and now I get to write them down.</p>
<h2>Mindset Shift</h2>
<p>Coding for Databricks using PySpark comes with a slight paradigm shift. Instead of the usual procedural approach, involving row-by-row processing with cursors and loops, one must think of how to do operations in bulk. How to setup dataframes with data transformations that will be lazily evaluated. Storage is not tightly coupled with the database engine, hence the usual context switching problems with PL/SQL hits extra hard. Understanding the columnar way the data is stored is important. There are no indexes and data is organized into files and partitions, whether explicitly or implicitly defined. Optimizing access paths is about eliminating partitions or files that do not need to be read.</p>
<h2>SQL vs PySpark Syntax</h2>
<p>Given that background, I present how an SQL statement actually looks like converted to PySpark. For example:</p>
<pre><code class="language-sql">SELECT ename, job, sal
FROM emp
WHERE job IN ('CLERK', 'ANALYST') 
  AND sal &gt; 1000
ORDER BY sal DESC;
</code></pre>
<p>This converted to PySpark looks like this:</p>
<pre><code class="language-python">from PySpark.sql import functions as F

df = spark.table("emp")

result = (
    df.filter(F.col("job").isin("CLERK", "ANALYST"))
    .filter(F.col("sal") &gt; 1000)
    .select("ename", "job", "sal")
    .orderBy(col("sal").desc())
)
</code></pre>
<p>Some things stand out immediately. Firstly, we start with the FROM clause which initializes the dataframe. The next part is a set of filters, a select clause, and an order by clause. This introduces the chaining syntax which can be useful for refactoring later on. I would also highlight the fact that with PySpark, the filters should normally be defined before the select clause. This brings me to some key takeaways I've come to know about and want to impart.</p>
<h2>Takeaway Boxes</h2>
<h3>Filtration is key</h3>
<p>Think about the filters first. It's a good rule because this makes one think of how to filter a table so that the least amount of rows are subjected to the next steps. Multiple filter functions can be chained as well to avoid the multitude of AND clauses which I can never decide when to cut to a new line and indent for readability.</p>
<h3>Be Lazy Until You Can't</h3>
<p>Running the code above segues into the concept of lazy evaluation. Data Engineers should be conscious of the operations that are eager actions which is the opposite of .</p>
<p>A <code>df.count()</code> actually executes, do not use it to check for the existence of a row.</p>
<p>A <code>df.collect()</code> executes and fetches all rows and puts it into an array of python rows which can easily be iterated. It should only be used on small data sets for possibly metadata.</p>
<p>A <code>df.show()</code> or a <code>display(df)</code> actually runs the query and displays it on the cell.</p>
<p>A <code>df.write()</code> should almost always be the last operation as this means the data frame has all the lazy transformations it needs and can now be written to a table, or the storage.</p>
<h3>Why not spark.sql</h3>
<p>Data engineers with insane SQL skills new to PySpark tend to write their queries and transformations in SQL statements when they are in a python notebook. Therein lies the problem, using <code>spark.sql()</code> means building dynamic SQL strings in python, F-strings can only get you so far. You've got to handle the WHERE clauses with multiple AND conditions. You've got to escape special characters. You'll have to handle subqueries and make sure that parenthesis are matching. Simple put, it quickly becomes a string manipulation exercise. Some of the points I raise regularly are:</p>
<ul>
<li><p>SQL Injection - this used to be a mortal sin in database development</p>
</li>
<li><p>Using spark.sql means everything is a string. Datatypes need to be casted properly from string. Special characters need to be escaped properly to become the correct value for the SQL statement.</p>
</li>
<li><p>Capitalization and SQL standards are optional and not enforced. Linting tools can easily refactor python PySpark functions, but the strings inside a spark.sql is free form.</p>
</li>
</ul>
<h3>Know your (data) types</h3>
<p>I can usually tell a very good engineer by how they handle data types. In databases, using the correct native datatype gives significant gains in performance and storage efficiency. Relying on implicit casting or leaving types ambiguous often leads to subtle bugs, data quality issues, and degraded performance.</p>
<p>In PySpark, a strong grasp of data types is essential because you are working across two systems:</p>
<ul>
<li><p>Python data types (e.g. int, float, str, datetime)</p>
</li>
<li><p>PySpark SQL data types (e.g. IntegerType, DoubleType, StringType, TimestampType)</p>
</li>
</ul>
<p>Misalignment between these can introduce silent errors, especially during ingestion, transformation, and writes. Timestamps (or datetime in python) can also be easily confusing. Using the python datetime variables help in getting all the nuances on timestamps in place rather than converting to specific datetime/timestamp string formats. Databricks also by default does not display all the microseconds when it implicitly converts timestamps to string. Hence loss of precision happens when someone naively just uses strings.</p>
<p>Explicitly cast literals on spark dataframes:</p>
<pre><code class="language-python">from PySpark.sql import functions as F
from datetime import datetime

current_time = datetime.now()

df.filter(F.col("id") == F.lit(123).cast("int"))
df.withColumn("extract_timestamp", F.lit(current_time).cast("timestamp"))
</code></pre>
<p>If needing to actually put datetimes into strings, use the isoformat. That format maintains readability and the option to put the desired time offset that makes comparison of the data easier later on.</p>
<h3>Null and None Matters</h3>
<p>Nulls are always a fun database concept. In PySpark, nulls actually highlight the importance of explicitly setting data types. In Python, <code>None</code> is the equivalent of the Spark <code>null</code>.</p>
<h2>Shuffling Out</h2>
<p>Wrapping up, I realize I haven't even discussed shuffling yet. I'll save that for later.</p>
<p>So shuffling out, the biggest shift when moving SQL/PLSQL-minded engineers to PySpark on Databricks isn’t just new syntax — it’s a different way of thinking about data. Instead of procedural, row-by-row logic, you design transformations as lazy, set-based operations on dataframes; instead of relying on indexes you reason about files, partitions and columnar layouts; and instead of micro-optimizations in a procedural loop you focus on eliminating unnecessary reads and choosing the right execution patterns.</p>
<p>For the next article I’ll dig into concrete patterns and anti-patterns — the idioms you’ll reach for most often and the traps you’ll want to avoid. I'll aim for runnable examples, checklist-style rules of thumb, and before/after comparisons so engineers coming from SQL or PL/SQL can map familiar concepts to PySpark best practices.</p>
]]></content:encoded></item><item><title><![CDATA[Counting when it really counts]]></title><description><![CDATA[A seasoned data engineer is deliberate on their use of expensive operations such as counts. It’s fairly common to see a misuse of this. Such as using a count to check if a query returns some rows. Or running a count query to figure out the number of ...]]></description><link>https://data.kurdapyo.com/counting-when-it-really-counts</link><guid isPermaLink="true">https://data.kurdapyo.com/counting-when-it-really-counts</guid><category><![CDATA[Databricks]]></category><category><![CDATA[dataframe]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Mon, 03 Nov 2025 11:16:29 GMT</pubDate><content:encoded><![CDATA[<p>A seasoned data engineer is deliberate on their use of expensive operations such as counts. It’s fairly common to see a misuse of this. Such as using a count to check if a query returns some rows. Or running a count query to figure out the number of records to be inserted by an insert-select statement.</p>
<p>Frankly speaking, doing a select count just to check the query will return some rows is inefficient and many systems I’ve come across are littered with samples of this. I won’t dwell into that too much here.</p>
<p>What I want to discuss is more on how to get the counts after running doing dataframe.write statements in spark.</p>
<h2 id="heading-the-old-traditions">The Old Traditions</h2>
<p>Let me start with how it used to be done with traditional databases. Across database systems, the method for determining the number of inserted rows differs. In MySQL, the <code>ROW_COUNT()</code> function returns the count of rows affected by the preceding <code>INSERT</code> statement. PostgreSQL offers a <code>RETURNING</code> clause within the <code>INSERT</code> statement itself, which can provide a count or return the inserted data. In SQL Server, the <code>@@ROWCOUNT</code> system variable holds the number of rows impacted by the most recent statement. Snowflake provides a <code>SQLROWCOUNT</code> variable for this purpose, specifically for DML statements within its scripting environment.</p>
<p>For Amazon Redshift, not really a traditional database but is a fork off good old postgres, obtaining an accurate insert count typically involves querying the <code>stl_insert</code> system table for the query ID of the recent <code>INSERT</code> operation, which can be more complex due to the distributed nature of the database. These variations mean developers must use the correct function or variable for their specific database to get an immediate and accurate count of inserted rows.</p>
<p>Simply put, there used to be values returned or special variables that provides a way to count.</p>
<h2 id="heading-breaking-from-tradition">Breaking from tradition</h2>
<p>Redshift was already hinting a little bit about the counting difficulties for distributed databases.</p>
<p>In my early days using databricks, I tried this:</p>
<pre><code class="lang-python">results = df.write \
  .format(<span class="hljs-string">"csv"</span>) \
  .option(<span class="hljs-string">"header"</span>, <span class="hljs-string">"true"</span>) \
  .mode(<span class="hljs-string">"overwrite"</span>) \
  .save(output_path)

print(results)
</code></pre>
<p>What a surprise to see this back:</p>
<blockquote>
<p><code>&gt; None</code></p>
</blockquote>
<p>A common workaround is to do a df.count() to get the count either before or after the operation to get the counts. This was okay in cases where the data is not changing in between operations. However, it means that the dataframe will actually be executed 2x albeit the count would be optimized to just get a count.</p>
<p>I explored this a bit further and found that accumulators can be used.</p>
<pre><code class="lang-python">sc = spark.sparkContext
rows_written_acc = sc.longAccumulator(<span class="hljs-string">"RowsWrittenCount"</span>)

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">increment_counter_and_return</span>(<span class="hljs-params">row</span>):</span>
    rows_written_acc.add(<span class="hljs-number">1</span>)
    <span class="hljs-keyword">return</span> row

df_rdd = df.rdd.map(increment_counter_and_return)

df.write \
  .format(<span class="hljs-string">"csv"</span>) \
  .option(<span class="hljs-string">"header"</span>, <span class="hljs-string">"true"</span>) \
  .mode(<span class="hljs-string">"overwrite"</span>) \
  .save(output_path)

print(rows_written_acc)
</code></pre>
<p>This code works BUT only if the cluster’s isolation mode is set to Single User. It rules out Unity Catalog so I had to rule it out instead. Back to the drawing board.</p>
<h2 id="heading-observing-counts">Observing counts</h2>
<p>My search for a better way to count or not count led me to the <a target="_blank" href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.observe.html">Dataframe.observe function</a>. This was introduced on Spark 3.3.1 and it computes the defined aggregates in an dataframe operation. This was exactly what I needed. I ended up with this:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> pyspark.sql <span class="hljs-keyword">import</span> Observation
<span class="hljs-keyword">from</span> pyspark.sql.functions <span class="hljs-keyword">import</span> lit, count

observation = Observation(<span class="hljs-string">"write-metrics"</span>)

df.observe(observation, count(lit(<span class="hljs-string">"1"</span>)).alias(<span class="hljs-string">"count"</span>)) \
  .write \
  .format(<span class="hljs-string">"csv"</span>) \
  .option(<span class="hljs-string">"header"</span>, <span class="hljs-string">"true"</span>) \
  .mode(<span class="hljs-string">"overwrite"</span>) \
  .save(<span class="hljs-string">"test.csv"</span>)

print(observation.get)
</code></pre>
<p>This returned me a dict of the aggregates I put on the observation:</p>
<blockquote>
<p><code>&gt; {'count': 4425435}</code></p>
</blockquote>
<p>More things can be added to the observe function. Things like getting the max timestamps or summing up values. This is basically what I needed for now.</p>
<p>It is important to note though that the Observation class works on batch queries. For streaming a slightly different approach is needed. But that is a topic for a future blog.</p>
<h2 id="heading-the-catch">The catch</h2>
<p>This approach worked well and good for 99.9% of our use case. However, I did observe that .1% of our processes got None returned on <code>observation.get</code>. I’m still getting to the bottom of it but I’ve observed that this happens when I try to overload the cluster with too much workload. I eventually added a check on that observation.get function to count in case it was null. Because I really don’t want to write a big file again. Wish me luck on this investigation and if I ever get to the reasons I’ll put an addendum to this article.</p>
]]></content:encoded></item><item><title><![CDATA[From For Loops to For Each]]></title><description><![CDATA[I'm a strong advocate for using the enterprise chosen orchestration tool for complex data pipelines. However, there are instances where using that strictly is not practical, and the scheduling tool within the database itself makes more sense.
Take Da...]]></description><link>https://data.kurdapyo.com/from-for-loops-to-for-each</link><guid isPermaLink="true">https://data.kurdapyo.com/from-for-loops-to-for-each</guid><category><![CDATA[Databricks asset bundles]]></category><category><![CDATA[Databricks]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Tue, 30 Sep 2025 08:15:55 GMT</pubDate><content:encoded><![CDATA[<p>I'm a strong advocate for using the enterprise chosen orchestration tool for complex data pipelines. However, there are instances where using that strictly is not practical, and the scheduling tool within the database itself makes more sense.</p>
<p>Take Databricks, for example. We create Databricks workflows, but the Databricks workflow itself acts as an orchestrator. The challenge lies in determining what should be executed as a single Databricks workflow and what should be tasks within that workflow.</p>
<h2 id="heading-the-problem-and-initial-approach"><strong>The Problem and Initial Approach</strong></h2>
<p>The problem I wanted to solve was flattening nested JSON data into multiple child tables. This required using the variant datatype to transform and store the data in standard tables with native datatypes. While creating a separate Databricks workflow for each table was possible, it wasn't practical. Instead, we chose to use a single Databricks workflow, triggered by the enterprise orchestration tool (Airflow), to process all the necessary tables. The simplest solution was to go through each table one by one to refine the flattening logic. This was version 1 of the implementation.</p>
<h2 id="heading-the-issue-with-the-loop"><strong>The Issue with the Loop</strong></h2>
<p>While this approach worked in most cases, the total processing time was the sum of all individual iterations. For example, if we had 10 tables that finished in 1 minute each and 2 tables that took 20 minutes each, the entire workflow would take 50 minutes to complete. Adding more compute power wouldn't significantly reduce this time and would only increase costs exponentially.</p>
<h2 id="heading-for-each-to-the-rescue">For Each to the Rescue</h2>
<p>This is where the "for each" Databricks task type becomes helpful. Instead of using a loop, I restructured the iterations into a single generic notebook, which became an individual Databricks workflow task. In the job definition, I can specify the individual tables in a JSON array that is formatted for YAML.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">tasks:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">task_key:</span> <span class="hljs-string">"process"</span>
    <span class="hljs-attr">for_each_task:</span>
      <span class="hljs-attr">inputs:</span> <span class="hljs-string">"[\"table_a\",\"table_b\",\"table_c\"]"</span>
      <span class="hljs-attr">concurrency:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">task:</span>
        <span class="hljs-attr">task_key:</span> <span class="hljs-string">"process_iterator"</span>
        <span class="hljs-attr">notebook_task:</span>
          <span class="hljs-attr">base_parameters:</span>
            <span class="hljs-attr">p_table:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{input}}</span>"</span>
          <span class="hljs-attr">notebook_path:</span> <span class="hljs-string">"process.py"</span>
</code></pre>
<h2 id="heading-a-pre-task-to-make-it-dynamic">A Pre-Task to Make it Dynamic</h2>
<p>That's all well and good, but sometimes we don't want to hardcode everything in the job definition. We want things to be dynamic.</p>
<p>This is where introducing a pre-task becomes useful. By using another task, we can set up some Python code that builds an array and passes it to the next tasks.</p>
<p>For example:</p>
<pre><code class="lang-python"><span class="hljs-comment"># sample logic to build a list for the foreach</span>
tables_to_process = [<span class="hljs-string">"table_1"</span>, <span class="hljs-string">"table_2"</span>]
tables_to_process.append(<span class="hljs-string">"table_3"</span>)

dbutils.jobs.taskValues.set(key=<span class="hljs-string">"tables_to_process"</span>, value=tables_to_process)
</code></pre>
<h3 id="heading-how-it-all-looks"><strong>How It All Looks</strong></h3>
<p>The job definition should look like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">tasks:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">task_key:</span> <span class="hljs-string">"prepare"</span>
    <span class="hljs-attr">notebook_task:</span>
      <span class="hljs-attr">notebook_path:</span> <span class="hljs-string">"prepare.py"</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">task_key:</span> <span class="hljs-string">"process"</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">task_key:</span> <span class="hljs-string">prepare</span>
    <span class="hljs-attr">for_each_task:</span>
      <span class="hljs-attr">inputs:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ tasks.prepare.values.tables_to_process }}</span>"</span>
      <span class="hljs-attr">concurrency:</span> <span class="hljs-number">4</span> <span class="hljs-comment"># or whatever</span>
      <span class="hljs-attr">task:</span>
        <span class="hljs-attr">task_key:</span> <span class="hljs-string">"process_iterator"</span>
        <span class="hljs-attr">notebook_task:</span>
          <span class="hljs-attr">base_parameters:</span>
            <span class="hljs-attr">p_table:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{input}}</span>"</span>
        <span class="hljs-attr">notebook_path:</span> <span class="hljs-string">"process.py"</span>
</code></pre>
<p>Once everything is set up, the Databricks job/workflow definition will have the "for each" task receiving input from the pre-task's output.</p>
<p>After deployment:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759148490477/ddedd549-a3b3-455b-a8b4-851177cbdde7.png" alt class="image--center mx-auto" /></p>
<p>In a dynamic "for each" setup, you won't see the actual list of tables or iterations beforehand. This list only appears when the job runs and finishes the pre-step. Once that's done, the "for each" task starts. Clicking on the "for each" task takes you to a page that looks like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759148613864/337c0043-a697-4101-a616-d81f4f6d0a37.png" alt class="image--center mx-auto" /></p>
<p>Interestingly, the iterations were not in alphabetical order, and I haven't needed to make them alphabetical.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>So there you have it, the "for each" loop. It's quite useful and can help improve serialized for loops if you have them.</p>
<p>I did encounter one limitation. Currently, the "for each" task can only take inputs from other tasks. It can't pass task values to the next tasks. My workaround was to append the results to a single table which the next tasks can query later.</p>
]]></content:encoded></item><item><title><![CDATA[Databricks Environment Splits]]></title><description><![CDATA[Three-tier architecture was the norm when I started my IT career. It consisted of the presentation layer (the web servers), the application layer (the app servers), and the data layer (the databases).
We could spin up as many environments as needed f...]]></description><link>https://data.kurdapyo.com/databricks-environment-splits</link><guid isPermaLink="true">https://data.kurdapyo.com/databricks-environment-splits</guid><category><![CDATA[Databricks]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Tue, 05 Aug 2025 09:39:43 GMT</pubDate><content:encoded><![CDATA[<p>Three-tier architecture was the norm when I started my IT career. It consisted of the <strong>presentation layer</strong> (the web servers), the <strong>application layer</strong> (the app servers), and the <strong>data layer</strong> (the databases).</p>
<p>We could spin up as many environments as needed for the presentation and application layers — most engineers typically had their own local setups for these. But the <strong>database layer?</strong> That was usually shared.</p>
<p>This setup works fine when the schema is stable and the only operations are data manipulation (DML: inserts, updates, deletes). But in my experience, that’s rarely the case.</p>
<p><strong>Data-heavy applications</strong> are always evolving — new tables here, an extra column there, another index for that slow join. Sharing a database across multiple developers and environments quickly becomes a source of friction.</p>
<h2 id="heading-modern-workarounds-for-database-isolation"><strong>Modern Workarounds for Database Isolation</strong></h2>
<p>Today, there are far more elegant ways to manage this. I've worked on systems where each developer spun up their own isolated data layer using <strong>Docker containers</strong> for PostgreSQL or MySQL. For Oracle, we found it more practical to use <strong>PDBs (Pluggable Databases)</strong> instead. In AWS-heavy environments, some teams spun up databases from <strong>RDS snapshots</strong> using <strong>CloudFormation</strong> or <strong>Terraform</strong>.</p>
<p>There are also broader solutions involving full-stack virtualization, infrastructure-as-code, and automation tools. These approaches help bring the <strong>data layer</strong> up to the same level of flexibility as the app and presentation layers — and reduce cross-team collisions along the way.</p>
<h2 id="heading-databricks-and-the-environment-problem"><strong>Databricks and the Environment Problem</strong></h2>
<p>But what happens when your “database layer” isn’t a traditional RDBMS at all?</p>
<p>What if it’s <strong>Databricks</strong>, where data lives in cloud-managed storage, and the interface revolves around notebooks and distributed compute jobs? In this world, <strong>environment isolation</strong> takes a different form — and requires different strategies.</p>
<h2 id="heading-what-is-an-environment-in-databricks"><strong>What <em>Is</em> an Environment in Databricks?</strong></h2>
<p>The first important question is: <strong>how do you define an environment in Databricks?</strong> There are several valid approaches.</p>
<p>A common pattern is to define an environment as a <strong>Databricks workspace</strong> — e.g., <code>kurdapyo-prd</code>, <code>kurdapyo-uat</code>, <code>kurdapyo-sit</code>, <code>kurdapyo-dev</code>. This gives you clean isolation. Pre–Unity Catalog, you could even reuse catalog names across workspaces. But with Unity Catalog, <strong>catalog names must be globally unique</strong>, so it's now common to prefix them — e.g., <code>gold_uat</code>, <code>gold_prd</code>.</p>
<p>The downside? Spinning up a new workspace environment isn’t cheap. On AWS, it often involves provisioning a new account and VPC, setting up DNS, configuring SCIM and user permissions — the kind of setup best left to platform teams or DevOps experts.</p>
<h2 id="heading-splitting-a-workspace-into-logical-environments"><strong>Splitting a Workspace into Logical Environments</strong></h2>
<p>The next logical approach is to carve out <strong>logical environments</strong> within a single Databricks workspace. We explored two main options:</p>
<ul>
<li><p>Splitting at the <strong>catalog level</strong></p>
</li>
<li><p>Splitting at the <strong>schema level</strong></p>
</li>
</ul>
<p>I personally prefer the <strong>schema-level split</strong>. I even advocated for building automation to create schemas on demand and using <strong>zero-copy cloning</strong> to generate like-for-like environments — perfect for safe, isolated testing.</p>
<p>However, that approach wasn’t prioritized at the time. Instead, we went with a <strong>catalog-level split</strong>, managed through Terraform to keep things clean and avoid ad-hoc changes by engineers.</p>
<h2 id="heading-naming-your-logical-environments"><strong>Naming Your Logical Environments</strong></h2>
<p>Once you've defined your environment boundaries, the next step is naming. While I prefer a “<strong>treat them as cattle, not pets</strong>” mindset, stakeholders usually prefer meaningful names.</p>
<p>A practical trick I’ve learned: <strong>always include a numeric suffix</strong>, like <code>dev-001</code> or <code>uat-02</code>. It sounds simple, but it's incredibly effective — because no matter how many environments you start with, you'll always need more later. This naming convention makes it easy to scale your environments without resorting to naming gymnastics.</p>
<h2 id="heading-aspirations-automation-and-production-parity"><strong>Aspirations: Automation and Production Parity</strong></h2>
<p>Once your environments and naming conventions are in place, the long-term goal is automation:</p>
<ul>
<li><p>Automate environment <strong>creation and teardown</strong></p>
</li>
<li><p>Make it easy to spin up <strong>production-like environments</strong></p>
</li>
<li><p>Enable <strong>regression testing</strong> in consistent, isolated setups</p>
</li>
</ul>
<p>These efforts can be time-consuming upfront but pay off significantly in delivery speed and engineering confidence. Of course, it’s essential to balance these aspirations with business priorities. Delivering value to users comes first — but carving out time for automation is what sets teams up for long-term success.</p>
<p>One tool I’m particularly curious about is <strong>SQLMesh</strong>. It promises branch-based environments, automated model testing, and declarative change tracking — all of which seem like a natural fit for solving the isolation problem at the data transformation layer. I haven’t tried it in anger yet, but it’s definitely on my list to explore as we continue refining our approach to Databricks environments.</p>
<h2 id="heading-final-thoughts"><strong>Final Thoughts</strong></h2>
<p>The challenges of environment isolation aren’t new — they just evolve with the tools we use. Whether it's a traditional database in a three-tier architecture or a modern platform like Databricks, the underlying principles remain the same: <strong>avoid shared mutable state</strong>, <strong>automate what you can</strong>, and <strong>build with repeatability in mind</strong>.</p>
]]></content:encoded></item><item><title><![CDATA[What's with a name (in Databricks)]]></title><description><![CDATA[Naming conventions are often a contentious topic among architects. In Databricks, these debates become even more pronounced due to the extensive platform work required to establish a Databricks implementation.
In an AWS-based Databricks setup, naming...]]></description><link>https://data.kurdapyo.com/whats-with-a-name-in-databricks</link><guid isPermaLink="true">https://data.kurdapyo.com/whats-with-a-name-in-databricks</guid><category><![CDATA[Databricks]]></category><category><![CDATA[unity catalog]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Fri, 11 Jul 2025 06:10:14 GMT</pubDate><content:encoded><![CDATA[<p>Naming conventions are often a contentious topic among architects. In Databricks, these debates become even more pronounced due to the extensive platform work required to establish a Databricks implementation.</p>
<p>In an AWS-based Databricks setup, naming conventions extend to AWS resources like VPNs, S3 buckets, IAM Roles, and IAM Policies. Additionally, with SCIM integration, service principals and groups are synced into Databricks, retaining their original names.</p>
<p>In Databricks, you'll encounter a mix of naming styles, including snake_case, kebab-case, camelCase, and PascalCase. This mix can include inconsistently named resources and occasionally misspelled ones that, for various reasons, aren't worth the effort to correct. It can be quite frustrating.</p>
<h2 id="heading-give-to-caesar-the-things-which-are-caesars">Give to Caesar the things which are Caesar’s</h2>
<p>Typically, different teams manage the cloud and Databricks environments. It's essential to adhere to the cloud team's established naming conventions. They service other technological needs aside from the data so they know their world best. Cloud resources often use kebab-case because bucket names become part of URLs. Interestingly, Google advises against using underscores in URLs, as they don't treat them as word separators (e.g., my_site is read as mysite).</p>
<p>Databricks start with the workspace name. It’s a name that is used in a URL and should be in kebab-case.</p>
<p>For groups and users, it is common practice to implement SCIM(System for Cross-domain Identity Management) Integration. What happens there is that the groups and users are synced from ActiveDirectory/IdentityNow and the groups and users are synced into databricks carrying over how it’s named. Service principals, I’ve always seen kebab-case used there. There is also the option of creating databricks groups which are separate from the SCIM integrated groups, these ones often just get the same convention for Service Principals.</p>
<h2 id="heading-enter-unity-catalog">Enter Unity Catalog</h2>
<p>Unity Catalog introduces additional complexity. Most resources, like Catalogs, Schemas, Tables, and Views, are used in SQL, where snake_case or UPPER_SNAKE_CASE is preferred. SQL syntax generally handles these well, as many databases aren't case-sensitive unless names are enclosed in double quotes. Using kebab-case can be problematic because the dash might be interpreted as a subtraction sign, necessitating double quotes.</p>
<p>PascalCase and camelCase are popular among Java developers and in SQLServer, but using these conventions require double quotes due to case sensitivity and spaces.</p>
<p>Beyond SQL objects, resources like storage credentials and external locations often follow kebab-case, especially when linked to S3 buckets.</p>
<h2 id="heading-abcs">ABCs</h2>
<p>Ultimately, the key to effective naming is to Always Be Consistent. While smaller projects often achieve this, larger implementations may struggle due to the involvement of many people, time constraints, and limited reviews. Therefore, in addition to striving for consistency, it's important to Also Be Considerate. People generally do their best with the knowledge and resources available, and while names that violate standards can be changed, consistency might not always be prioritized due to other pressing business needs.</p>
]]></content:encoded></item><item><title><![CDATA[IAC Face-Off: Databricks Asset Bundles vs Terraform]]></title><description><![CDATA[Data Engineers often have to juggle both data processing and DevOps pipelines. I’ve worked with many skilled data professionals, but they tend to shy away from the DevOps side of things. Often, I get asked to take over this aspect due to my backgroun...]]></description><link>https://data.kurdapyo.com/iac-face-off-databricks-asset-bundles-vs-terraform</link><guid isPermaLink="true">https://data.kurdapyo.com/iac-face-off-databricks-asset-bundles-vs-terraform</guid><category><![CDATA[Databricks]]></category><category><![CDATA[Databricks asset bundles]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Sun, 06 Jul 2025 12:13:28 GMT</pubDate><content:encoded><![CDATA[<p>Data Engineers often have to juggle both data processing and DevOps pipelines. I’ve worked with many skilled data professionals, but they tend to shy away from the DevOps side of things. Often, I get asked to take over this aspect due to my background.</p>
<p>For Databricks, resource deployment has always been a combination of Terraform and Databricks Asset Bundles. Terraform likely needs no introduction, as it's prevalent in infrastructure deployment nowadays. Other options like Pulumi or even Ansible is a distant second. While I’m no expert, I have a working understanding of how to implement changes with it.</p>
<p>Databricks Asset Bundles (DABs), however, are not as widely known. Before DABs, deploying jobs and workflows in Databricks was converging using DBX. Eventually, Databricks added the bundle command to the Databricks CLI, and it’s now being pushed more and more.</p>
<p>When I first heard about DABs from our Solutions Architect, it seemed like we could use it for all resource deployments. I was disappointed to find out later that it only deployed Workflows and DLT Pipelines.</p>
<h2 id="heading-whats-missing">What's Missing?</h2>
<p>All the platform-related components, of course. Our project involved setting up Databricks on AWS from scratch, and we aimed to minimize manual configurations (clickops) while adhering to strict security protocols. Both Account Admin and workspace admin privileges were tightly controlled, as they should be.</p>
<p>We needed to deploy everything from the metastore, workspaces, groups, and service principals to catalogs, schemas, tables, views, and grants. However, aside from Workflows and DLT Pipelines, DABs couldn't create any of these components—not even tables and views. While we could write code to create these using the Databricks API, it required running them through notebooks, leading to less elegant, more cumbersome code compared to the declarative style of Terraform.</p>
<h2 id="heading-what-we-did-eventually">What we did eventually</h2>
<p>Terraform continues to be our primary tool for provisioning most resources, including Catalogs, Schemas, External Locations, and Volumes. It also manages Shared Databricks Clusters and SQL Warehouses effectively. To maintain simplicity and security, we limit grants to the Schema level, avoiding individual object grants.</p>
<p>Alas, Terraform is not very common in our current crew of data engineers. This equated to a higher platform engineer to data engineer ratio for us.</p>
<p>We still rely on Databricks Asset Bundles for deploying Databricks workflows, as they offer a straightforward method to deploy the same code across different environments.</p>
<p>For creating tables and views, we utilize DBT (Data Build Tool), which provides a declarative approach to object creation. As for User-Defined Functions (UDFs), they might still require the less elegant notebook approach, but fortunately, we haven't needed to create any yet.</p>
<p>I am hopeful that Databricks Asset Bundles will eventually support a broader range of resources. The tool is rapidly evolving, and I remain optimistic about its future capabilities.</p>
]]></content:encoded></item><item><title><![CDATA[Fivetran Schema Change Handling]]></title><description><![CDATA[Schema changes can be a significant challenge for many ETL pipelines. In the past, changes were more manageable because systems operated independently. However, with the rise of SaaS products, out-of-the-box solutions, and frequent release cycles, ch...]]></description><link>https://data.kurdapyo.com/fivetran-schema-change-handling</link><guid isPermaLink="true">https://data.kurdapyo.com/fivetran-schema-change-handling</guid><category><![CDATA[fivetran]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Fri, 04 Jul 2025 10:45:44 GMT</pubDate><content:encoded><![CDATA[<p>Schema changes can be a significant challenge for many ETL pipelines. In the past, changes were more manageable because systems operated independently. However, with the rise of SaaS products, out-of-the-box solutions, and frequent release cycles, change has become a constant, and occasional schema changes must be addressed.</p>
<h2 id="heading-setting-the-scene"><strong>Setting the Scene</strong></h2>
<p>In our scenario, our data source is a Snowflake database managed by a SaaS vendor. We decided to use Fivetran to sync data from Snowflake into our target database. The SaaS vendor follows a weekly release cycle for minor updates and a monthly cadence for major releases, with changes occurring in different environments on varying dates.</p>
<p>We set up Fivetran replication from this Snowflake source. Our connector is configured to sync a substantial number of tables, and we have been syncing daily for several days without issues.</p>
<h2 id="heading-cue-in-the-schema-change"><strong>Cue in the Schema Change</strong></h2>
<p>One day, our sync did not complete successfully. We received a status of “Rescheduled” with the reason “Unsupported schema change requires table resync: ADD_COLUMN” for one of the tables.</p>
<p>As we were still new to analyzing the Fivetran logs (not available on the Fivetran Console), we were unsure of what had happened. We asked some questions within the team, and while it was a learning experience, it was challenging at the time. Here are the questions we faced:</p>
<h3 id="heading-question-1-what-column-was-added">Question 1: What column was added?</h3>
<p>This information was not readily available. However, by querying the Fivetran logs for <code>alter_table</code> events, we could identify the table and the column that was added.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span>   *
<span class="hljs-keyword">FROM</span>     &lt;destination_db&gt;.&lt;destination_schema&gt;.log
<span class="hljs-keyword">WHERE</span>    conector_id = :connector_id
<span class="hljs-keyword">AND</span>      message_event = <span class="hljs-string">'alter_table'</span>
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> time_stamp <span class="hljs-keyword">DESC</span>;
</code></pre>
<h3 id="heading-question-2-what-happened-to-the-sync-of-the-other-tables">Question 2: What happened to the sync of the other tables?</h3>
<p>We were initially unaware of the status of the other tables. The Fivetran Console did not provide this information. However, we later learned that the other tables had indeed been synced. This information can also be queried from the logs.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span>   message_data:<span class="hljs-keyword">schema</span> <span class="hljs-keyword">AS</span> <span class="hljs-string">"schema"</span>,
         message_data:<span class="hljs-keyword">table</span> <span class="hljs-keyword">AS</span> <span class="hljs-string">"table"</span>,
         message_data:<span class="hljs-keyword">count</span>
<span class="hljs-keyword">FROM</span>     &lt;destination_db&gt;.&lt;destination_schema&gt;.log
<span class="hljs-keyword">WHERE</span>    conector_id = :connector_id
<span class="hljs-keyword">AND</span>      message_event = <span class="hljs-string">'records_modified'</span>
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> time_stamp <span class="hljs-keyword">DESC</span>;
</code></pre>
<h3 id="heading-question-3-should-we-just-rerun-the-sync">Question 3: Should we just rerun the sync?</h3>
<p>If we had relied on the Fivetran scheduler, it would have retried the sync automatically without any intervention from us. So, we decided to rerun the sync.</p>
<p>What we now understand is that Fivetran, on that first sync that ended in a reschedule, had already altered the table with the schema change. At this point, it simply required an acknowledgment to proceed with a full sync for that table.</p>
<p>This realization was surprising. I initially thought that Fivetran would detect the schema change and, upon rerunning the sync, would then alter the table and perform the sync. However, that was not the case.</p>
<p>This issue is somewhat unique to us because we do not own the source. But imagine this happening to a table of multi-terabyte size. Attempting to resync could take days or even weeks. And there was no way of getting around it.</p>
<h2 id="heading-rtfm">RTFM</h2>
<p>The documentation clarified the issue further and the behavior we saw.</p>
<p><a target="_blank" href="https://fivetran.com/docs/connectors/databases/snowflake#automatictableresyncs">https://fivetran.com/docs/connectors/databases/snowflake#automatictableresyncs</a></p>
<blockquote>
<h3 id="heading-automatic-table-re-syncs">Automatic table re-syncs</h3>
<p>For tables with a primary key, we support ADD COLUMN DDL operations with null or default static values. All other table or column alterations trigger an automatic table re-sync. For tables without a primary key, any DDL operation triggers an automatic table re-sync.</p>
</blockquote>
<p>Apparently the fault was ours. Though we can’t really do anything about it as the snowflake source is vendor owned.</p>
<h2 id="heading-what-to-do-now">What to do now</h2>
<p>Sad to say, not much could be done.</p>
<p>We educated support about this and watch out and just prepare communications on these occurences.</p>
<p>We raised the issue to fivetran and made a feature request.</p>
<p>Now we wait.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">📧</div>
<div data-node-type="callout-text">We'd love to hear from you! Share your experiences with schema changes in ETL processes in the comments below. Have questions or feedback on the strategies discussed? Don't hesitate to reach out.</div>
</div>]]></content:encoded></item><item><title><![CDATA[Upset with Fivetran Scheduler Offsets]]></title><description><![CDATA[A few months ago, I finally had the opportunity to use Fivetran in a real-world project. To provide some background, this was a greenfield project involving a financial system, tooling and architecture had been selected before my arrival. The tech st...]]></description><link>https://data.kurdapyo.com/upset-with-fivetran-scheduler-offsets</link><guid isPermaLink="true">https://data.kurdapyo.com/upset-with-fivetran-scheduler-offsets</guid><category><![CDATA[control-m]]></category><category><![CDATA[fivetran]]></category><category><![CDATA[Orchestration]]></category><dc:creator><![CDATA[Kurdapyo Data Engineer]]></dc:creator><pubDate>Tue, 24 Jun 2025 09:44:20 GMT</pubDate><content:encoded><![CDATA[<p>A few months ago, I finally had the opportunity to use Fivetran in a real-world project. To provide some background, this was a greenfield project involving a financial system, tooling and architecture had been selected before my arrival. The tech stack included Databricks for the Data Lake (or Delta Lake), with Fivetran chosen as the data acquisition tool. The company was using Control-M as its scheduler, but no one really wanted to use that. For this specific task, it was decided to utilize the Fivetran Scheduler to trigger the data acquisition processes.</p>
<h2 id="heading-my-mission-or-so-i-thought">My Mission (or so I thought)</h2>
<p>I was brought into this project to work on Databricks components. Despite this, I was eager to see Fivetran in action. Architect colleagues I’ve collaborated with for DMS often touted how much easier Fivetran will make such acquisition or migration tasks.</p>
<p>I diligently explored how to orchestrate the data acquisition with Databricks workflows. As expected, Databricks didn’t have direct connectivity to Fivetran. This is a common security practice. It prevents data engineers from being tempted to create code to directly access the Fivetran APIs, potentially leading to messy implementations.</p>
<p>I expressed concerns that orchestration should employ the preferred orchestration tool, despite it being considered outdated technology. Unfortunately, my concerns were ignored. With my well-defined JIRA card languishing without any progress, I decided to focus and proceed with the work regardless.</p>
<p>With no API connectivity, I began examining Fivetran logs, which were being synced by Fivetran to Databricks. BTW, Fivetran doesn’t provide logs on its web console, and the way a customer can access logs is by syncing log data to a destination using a Fivetran connector. (This novel topic might be a future blog.)</p>
<p>The Fivetran log connector had a 5-15 minute delay which was acceptable for now. SLA's will be dealt with much later on.</p>
<h2 id="heading-initial-signs">Initial Signs</h2>
<p>We had a job scheduled at 4 p.m. From the logs, I noticed syncs were only starting around 4:11 p.m. This seemed odd, but I didn't think much of it at the time, as it was still early in the project and nothing was stable yet.</p>
<p>After a few more weeks, we were preparing to promote the code to the next higher environment. We deployed the same code (Fivetran was deployed using Terraform, by the way) for a 4 p.m. sync. We waited, but by 4:15, the job hadn't started. By 4:30, still nothing. 4:45, nothing. Finally, the job only started at 4:54 p.m.</p>
<h2 id="heading-rtfm">RTFM</h2>
<p>Digging through the Fivetran documentation, I stumbled upon this warning:</p>
<p><a target="_blank" href="https://fivetran.com/docs/core-concepts/syncoverview">https://fivetran.com/docs/core-concepts/syncoverview</a></p>
<blockquote>
<p>When you add a new destination, Fivetran assigns it a fixed time offset. The offset can be any random value in minutes ranging from 0 to 60. It is derived from the destination ID hash. This offset is shared by every connector in the destination. The offset value remains the same regardless of the set sync frequency.</p>
</blockquote>
<p>Let that sink in: a random 0-60 minute offset. And it can’t be modified, even through a support request (trust me, I tried).</p>
<h2 id="heading-denouement">Denouement</h2>
<p>This discovery threw a wrench in our plans to avoid using Control-M. After losing a moderate amount of effort on creating a log-based solution, we pivoted to developing a simple script to be triggered by Control-M. In terms of code, simpler solution. However, in terms of infrastructure, it was a minor nightmare. We had to sort out an agent for Control-M, open up network connectivity, create a Fivetran service user to trigger the API calls, and manage secrets, among other tasks.</p>
<h2 id="heading-things-i-learned">Things I learned</h2>
<p>Avoid the fivetran scheduler like the plague, the API is the way to go to trigger fivetran syncs. You won’t be hit by an unreasonably high offset. And you’ll have more options to handle reschedules or errors on syncs. I’ll rant about the Fivetran Scheduler retry logic in another time.</p>
<p>Embrace the orchestration tool as your ally. I still do not like Control-M. While Control-M may not be my preferred choice, I recognize the robust infrastructure this organization has established around it, including change management processes, 24/7 support teams, alerts, and more. It does make me think of what hurdles needs to be scaled to get something like Airflow productionized.</p>
]]></content:encoded></item></channel></rss>