<?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" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Angel Conde on Medium]]></title>
        <description><![CDATA[Stories by Angel Conde on Medium]]></description>
        <link>https://medium.com/@neuw84?source=rss-7442e895eb8b------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*0i6e51oI1yQlqAPGJ1tbPQ.png</url>
            <title>Stories by Angel Conde on Medium</title>
            <link>https://medium.com/@neuw84?source=rss-7442e895eb8b------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 06:07:42 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@neuw84/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Accelerating Spark with the Java Vector API]]></title>
            <link>https://medium.com/@neuw84/accelerating-spark-with-the-java-vector-api-5796e12bf979?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/5796e12bf979</guid>
            <category><![CDATA[arrow-functions]]></category>
            <category><![CDATA[apache-spark]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Fri, 25 Sep 2026 21:34:57 GMT</pubDate>
            <atom:updated>2026-09-29T08:04:46.513Z</atom:updated>
            <content:encoded><![CDATA[<p>Apache Spark has had a columnar execution direction for some time, and projects such as <a href="https://datafusion.apache.org/comet/">Apache DataFusion Comet</a> have demonstrated how much performance can be gained by moving away from row-at-a-time execution. But there is another interesting question:</p><blockquote>Can we bring SIMD-style execution directly into Spark’s JVM execution path, using Java itself?</blockquote><p>That is the idea behind <a href="https://vecruntime.github.io/vecruntime/">VecRuntime</a>, a JVM-native vectorized execution engine for Spark SQL. The project uses the <strong>Java Vector API</strong> to execute Spark SQL operators over Arrow-layout columnar batches. Unsupported operations simply fall back to Spark like on Comet.</p><p>Consider a completely ordinary PySpark query:</p><pre>df = (<br>    sales<br>    .where(...)<br>    .groupBy(...)<br>    .agg(...)<br>    .orderBy(...)<br>)</pre><p>There is nothing VecRuntime specific here. The application still uses the standard DataFrame API. The difference happens later.</p><p>Spark generates a physical plan and VecRuntime applies a planning rule that replaces supported Spark operators with vectorized implementations.</p><p>Anything that cannot be converted remains a normal Spark operator. That fallback model is important because it means acceleration can be incremental rather than all-or-nothing.</p><h3>Why the JVM?</h3><p>When people hear “vectorized execution”, they often think of native code. That is a reasonable assumption. Modern analytical engines frequently use C++, SIMD intrinsics, native memory layouts, and specialized runtimes. <strong>VecRuntime</strong> takes a different route. Its kernels are implemented in Java using the Java Vector API. The Java Vector API allows code to operate on multiple values per instruction while still remaining inside the JVM.</p><p>On an ARM64 machine, the JVM can use the available NEON or SVE vector instructions. On machines with wider x86 SIMD capabilities, the Vector API can select wider species automatically like AVX512.</p><h3>What does VecRuntime accelerate?</h3><p>The project currently implements vectorized versions of several core Spark SQL operators covering TPC-H and TPC-DS queries (details <a href="https://vecruntime.github.io/vecruntime/compatibility.html">here</a>).</p><p>However, the important part is not simply the number of operators. It is the <strong>composability</strong>. A vectorized Filter can feed a vectorized Aggregate, which can feed another vectorized operator, without converting back to Spark rows between every step.</p><p>That is where the architecture starts becoming interesting.</p><h3>The columnar representation</h3><p>VecRuntime operates on columnar batches using an Arrow-compatible layout. That means an execution pipeline can look like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Pl_gbQlS9M_D0O7NLyA5YA.png" /></figure><p>The operators work directly on columns rather than repeatedly materializing UnsafeRow objects. This matters because a vectorized operator is only useful if the surrounding pipeline preserves the vectorized representation. And that leads to one of the biggest lessons from the project.</p><h3>The hidden cost: shuffle boundaries</h3><p>Suppose we have:</p><pre>VectorAggregate -&gt; Spark Shuffle -&gt; VectorAggregate</pre><p>Spark’s traditional shuffle machinery is fundamentally row-oriented. That means a columnar batch can end up going through a sequence conceptually similar to:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wdJ22to0LN7JpUsyVcj-9A.png" /></figure><p>The vectorized operator may be faster, but the conversion overhead can erase much of the benefit. This is why the example repository contains an optional <strong>columnar shuffle</strong>.</p><h4>A shuffle designed for columnar execution</h4><p>The VecRuntime shuffle moves Arrow record batches rather than rows. The architecture is roughly:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7PSxCjP2iBH768jmh-1ekg.png" /><figcaption>Columnar Shuffle overview</figcaption></figure><p>For remote shuffle, the current implementation uses <a href="https://arrow.apache.org/docs/format/Flight.html"><strong>Apache Arrow Flight</strong></a><strong> over gRPC</strong>. The important property is not the transport itself.</p><blockquote><strong><em>The data can remain columnar across the shuffle boundary.</em></strong></blockquote><p>That removes the row-to-columnar conversion that otherwise happens at every stage boundary.</p><h3>The PySpark example</h3><p>The example repository was built specifically to demonstrate this approach without requiring a large Spark deployment.</p><p>The repository uses uv to manage the Python environment ( you will need Java 25too). The quick start is simply:</p><p>The repository uses uv to manage the Python environment.</p><p>The quick start is simply:</p><pre>git clone https://github.com/vecruntime/pyspark-example<br>cd pyspark-example<br>uv run example.py</pre><p>The first execution generates a small star schema:</p><pre>sales<br>  ~20 million rows<br>stores<br>  1,000 rows</pre><p>The generated data is written as Parquet and reused across subsequent runs. This gives us a simple but realistic analytical workload without needing an external dataset.</p><p>The example contains three DataFrame queries.</p><h4>1. A TPC-H Q1-style aggregation</h4><p>The first query filters rows, computes derived expressions, performs a grouped aggregation and orders the result. Conceptually:</p><pre>Parquet scan<br>     ↓<br>Filter<br>     ↓<br>HashAggregate<br>     ↓<br>Sort</pre><p>This is a particularly good shape for vectorization because it contains arithmetic, predicates and aggregation over large numbers of rows.</p><h4>2. A broadcast join and top-N query</h4><p>The second query joins the large sales table with the small stores dimension:</p><pre>sales<br>   │<br> Filter<br>   │<br>   ├──────── Broadcast stores<br>   │<br> BroadcastHashJoin<br>   │<br> Aggregate<br>   │<br> TakeOrdered / LIMIT</pre><p>This tests a different part of the engine: joins plus aggregation plus top-N processing.</p><h4>3. Ranking within each region</h4><p>The third query performs an aggregation followed by a ranking window:</p><pre>Join<br>  ↓<br>Aggregate<br>  ↓<br>Window / Rank<br>  ↓<br>Filter rank &lt;= 3</pre><p>This is useful because it combines several operators and exercises the ability to keep a larger section of the plan columnar.</p><h3>The most useful part: same code, two execution modes</h3><p>The example deliberately runs each query twice with just a config change. It also inspects the final physical plan to show which operators were accelerated.</p><p>This makes the experiment much more interesting than simply comparing two unrelated implementations.</p><p>We are comparing:</p><pre>Same PySpark code<br>Same Spark session<br>Same data<br>Same query<br>        │<br>        ├── Spark execution<br>        │<br>        └── VecRuntime execution</pre><h3>What does the example show?</h3><p>On my Apple M3 Pro system, using Spark 4.1.3 and 20 million rows, the repository reports the following indicative results ( relative speed up):</p><ul><li><strong>1.19×</strong> for the aggregation query</li><li><strong>1.24×</strong> for the join/top-N query</li><li><strong>1.17×</strong> for the ranking query</li></ul><p>These are intentionally presented as indicative rather than universal benchmarks. The example is running locally on one machine, the machine was doing other work, and the data size is relatively small.</p><p>The more interesting observation is what happens <strong>without</strong> the columnar shuffle. For this workload, the README notes that stage boundaries can spend enough time converting between columnar batches and rows that much of the operator-level gain disappears.</p><blockquote>Faster operators alone are not enough. The execution pipeline has to remain columnar.</blockquote><h3>The kernels are where the fun starts</h3><p>The repository also contains lower-level JMH benchmarks for the Java kernels. These are <strong>kernel-level</strong> numbers, not end-to-end Spark query speedups.</p><p>That distinction matters enormously. A kernel can be 8× faster while the overall query only becomes 1.2× faster because the application also spends time scanning Parquet, scheduling tasks, shuffling, materializing results, performing unsupported expressions and so on.</p><blockquote>This is precisely why the columnar pipeline matters.</blockquote><h3>Aggregation is more subtle</h3><p>Vectorization is not automatically beneficial everywhere. Grouped aggregation is a good example.</p><p>A naive implementation might perform a SIMD reduction separately for every group. That sounds attractive.</p><p>But imagine four groups and a batch of thousands of rows. You don’t want to scan the entire batch four times. The implementation therefore uses a scatter-oriented approach where rows update grouped accumulators.</p><blockquote>SIMD alone doesn’t make an algorithm efficient.</blockquote><p>The algorithm still has to respect memory access patterns, cache behavior and data dependencies.</p><h3>Floating-point correctness is another trade-off</h3><p>There is another interesting issue with vectorized aggregation: floating-point associativity. Spark’s normal aggregation order can produce a particular rounding result. A vectorized reduction may use multiple partial sums and therefore perform additions in a different order.</p><p>But floating-point arithmetic can produce slightly different results. VecRuntime therefore has a strict floating-point mode. With strict mode enabled, it can reproduce Spark’s rounding semantics rather than simply maximizing SIMD throughput.</p><p>This is a useful example of a principle that gets overlooked in vectorized execution projects:</p><blockquote>Performance is only useful when the semantics are correct<strong><em>.</em></strong></blockquote><h3>And this is still Spark</h3><p>One of the architectural decisions I particularly like is the fallback behavior. VecRuntime does not try to implement every Spark expression and operator before users can try it. Instead, it can partially accelerate a plan. For example:</p><pre>VectorFilter<br>     ↓<br>VectorProject<br>     ↓<br>SparkPythonUDF<br>     ↓<br>SparkWrite</pre><p>The vectorized operators run where possible, and Spark handles the rest. The README lists unsupported or non-vectorized areas (yet). This makes the approach much closer to an accelerator layer than a replacement Spark engine.</p><h3>There is also a Spark UI</h3><p>One of the nicest features of the example is the <strong>Vector Acceleration</strong> tab in the Spark UI.</p><p>With:</p><pre>uv run example.py --keep-alive</pre><p>The example keeps the Spark session alive and exposes the Spark UI. The custom tab visualizes the execution plan and shows which operators were executed by VecRuntime and which remained on Spark.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mrLl0BfPck0nGfNwWYYKGQ.png" /><figcaption>Detail plan of a SQL query</figcaption></figure><p>That is especially useful for debugging partial acceleration. Instead of asking:</p><blockquote>“Why didn’t this query speed up?”</blockquote><p>you can ask:</p><blockquote>“Which operator prevented the pipeline from remaining vectorized?”</blockquote><p>That is a much more actionable question.</p><h3>Looking beyond a local laptop</h3><p>The local PySpark example is intentionally simple. The main project has also been tested on larger workloads, including TPC-H and TPC-DS.</p><p>The current project documentation reports a 1 TB TPC-DS comparison on EKS using eight 13-core executors.</p><p>In that configuration, the documented current as publication date totals are:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JVYLDRB39gZ52vuBmQCrOQ.png" /></figure><p>They demonstrate that a JVM-native execution path can be pushed far beyond a small local experiment and can execute a substantial analytical workload.</p><p>The project reports that accelerated 82 of the 103 TPC-DS queries in that campaign.</p><h3>JVM-native does not mean anti-native</h3><p>An interesting part of the design is that the project is designed to coexist with <strong>Apache DataFusion Comet</strong>.</p><p>A potential pipeline looks like:</p><pre>Native Parquet decoding<br>        ↓<br>Arrow / columnar representation<br>        ↓<br>VecRuntime<br>        ↓<br>Comet or other native component</pre><p>The common currency is the columnar representation. That opens up a more interesting architecture than the usual:</p><blockquote>JVM engine vs native engine</blockquote><p>Instead, we can think about:</p><blockquote>Different execution components sharing the same columnar memory representation.</blockquote><p>That is a much more composable model.</p><h3>Final thoughts</h3><p>There is a lot of attention on native execution engines for Spark. And for good reason. But the Java ecosystem has an interesting capability that is still relatively under-explored for analytical execution: the <strong>Java Vector API</strong>.</p><p><strong>VecRuntime</strong> is an experiment around that idea. The goal is not to replace Spark. It is to ask whether Spark’s familiar execution model can be pushed further by:</p><blockquote>Not necessarily need to leaving the JVM to get a vectorized engine.</blockquote><h3>Resources</h3><ul><li><strong>VecRuntime:</strong> <a href="https://github.com/vecruntime/vecruntime">https://github.com/vecruntime/vecruntime</a></li><li><strong>PySpark example:</strong> <a href="https://github.com/vecruntime/pyspark-example">https://github.com/vecruntime/pyspark-example</a></li><li><strong>Java Vector API:</strong> <a href="https://openjdk.org/jeps/338">https://openjdk.org/jeps/338</a></li><li><strong>Apache DataFusion Comet:</strong> <a href="https://github.com/apache/datafusion-comet">https://github.com/apache/datafusion-comet</a></li><li><strong>Apache Arrow:</strong> <a href="https://arrow.apache.org/">https://arrow.apache.org/</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5796e12bf979" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Compiling DuckDB’s AWS extension to WebAssembly: querying Amazon S3 Tables from a browser tab]]></title>
            <link>https://medium.com/@neuw84/i-compiled-duckdbs-aws-extension-to-webassembly-and-queried-amazon-s3-tables-from-a-browser-tab-f44debff81cc?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/f44debff81cc</guid>
            <category><![CDATA[data-lakehouse]]></category>
            <category><![CDATA[duckdb]]></category>
            <category><![CDATA[wasm]]></category>
            <category><![CDATA[s3-tables]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Fri, 28 Aug 2026 11:46:49 GMT</pubDate>
            <atom:updated>2026-08-28T21:43:36.877Z</atom:updated>
            <content:encoded><![CDATA[<p><em>DuckDB-Wasm can’t load the </em><em>aws extension today. Three small patches later, a React app in my browser was committing Iceberg tables to Amazon S3 Tables and querying them back.</em></p><h3>The itch</h3><p>If you’ve used <a href="https://github.com/duckdb/duckdb-wasm">DuckDB-Wasm</a>: the WebAssembly build of DuckDB that runs entirely in the browser and want to use with AWS platform you may have hit this:</p><pre>CREATE SECRET s3 (TYPE S3, PROVIDER CREDENTIAL_CHAIN);</pre><pre>Extension Autoloading Error: An error occurred while trying to automatically<br>install the required extension &#39;aws&#39;:<br>Extension https://extensions.duckdb.org/v1.1.3/wasm_eh/aws.duckdb_extension.wasm<br>is not available</pre><p>The aws extension simply isn&#39;t distributed for the wasm platforms. There&#39;s a long-standing issue about it (<a href="https://github.com/duckdb/duckdb-wasm/issues/1919">duckdb-wasm#1919</a>), and the extension&#39;s CI config is refreshingly blunt about why:</p><pre>exclude_archs: &#39;wasm_mvp;wasm_eh;wasm_threads;...&#39; # Doesn&#39;t work anyway</pre><p>“Doesn’t work anyway” is a challenge if I’ve ever read one. I wanted to know two things: <strong>does it actually not compile</strong>, and if we fix that, <strong>is it actually useless in a browser?</strong></p><p>Spoiler: it didn’t compile, the fixes are small, and it’s much less useless than expected.</p><h3>Blocker #1: the AWS event loop that wasn’t there</h3><p>The aws extension pulls in the full native AWS stack via vcpkg: aws-sdk-cpp, the AWS Common Runtime (CRT) libraries, OpenSSL, curl, s2n, zlib. My assumption was that this pile of native networking code would explode immediately on wasm32-emscripten.</p><p>It mostly… didn’t. OpenSSL built. curl built. s2n built. Almost every aws-c-* library built. Exactly one dependency failed:</p><pre>CMake Error at CMakeLists.txt:170 (message):<br>  Event Loop is not setup on the platform.</pre><p>aws-c-io — the CRT&#39;s socket and event-loop layer — selects an implementation per platform: IOCP on Windows, epoll on Linux, kqueue on BSD, dispatch queues on Apple. Emscripten matches none of the branches, and the build hard-fails.</p><p>Here’s the thing though: the extension never needs an event loop. Its code paths go through the SDK’s classic curl-style HTTP client, not the CRT’s async engine. And aws-c-io&#39;s own source already contains graceful runtime stubs for unsupported loop types — the <em>only</em> hard failures were the CMake check and one #error in the default-type lookup.</p><p>So the fix is a <a href="https://learn.microsoft.com/en-us/vcpkg/concepts/overlay-ports">vcpkg overlay port</a> with a small patch: add an Emscripten branch that compiles the POSIX sources (sockets, pipes, DNS — these build cleanly against Emscripten’s musl libc) and ships <strong>no</strong> event loop. Creating one at runtime returns AWS_ERROR_PLATFORM_NOT_SUPPORTED instead of failing the build:</p><pre>elseif (CMAKE_SYSTEM_NAME STREQUAL &quot;Emscripten&quot;)<br>    file(GLOB AWS_IO_OS_SRC &quot;source/posix/*.c&quot;)<br>    list(APPEND EVENT_LOOP_DEFINES &quot;EMSCRIPTEN_NOOP&quot;)<br>    set(USE_S2N ON)</pre><p>With the overlay in place, the <em>entire</em> dependency tree — aws-sdk-cpp included — built for wasm32-emscripten. First surprise of the project.</p><h3>Blocker #2: the 420 KB extension that should have been 12 MB</h3><p>The build produced aws.duckdb_extension.wasm… at 420 KB. For something that statically links the AWS SDK, that&#39;s not lean, that&#39;s <em>empty</em>. Inspecting the module confirmed it: thousands of Aws::* symbols were unresolved imports, expected to come from the main DuckDB module at load time. They never would — DuckDB-Wasm doesn&#39;t contain the AWS SDK.</p><p>The cause is a quirk of how DuckDB builds wasm extensions. A loadable wasm extension isn’t linked by CMake’s normal machinery; it’s produced by a post-build re-link:</p><pre>emcc &lt;extension-archive&gt; -o &lt;name&gt;.duckdb_extension.wasm -O3 -sSIDE_MODULE=2 ${LINKED_LIBS}</pre><p>target_link_libraries is ignored for this step entirely. Any third-party static libraries must be passed explicitly through a LINKED_LIBS parameter in the extension&#39;s extension_config.cmake — which the aws extension never sets, because nobody had ever linked it for wasm.</p><p>The fix is boring and beautiful — list the vcpkg-built archives:</p><pre>duckdb_extension_load(aws<br>    SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}<br>    LOAD_TESTS<br>    LINKED_LIBS &quot;../../vcpkg_installed/wasm32-emscripten/lib/libaws-cpp-sdk-core.a ...&quot;<br>)</pre><p>Result: a 9 MB side module where every AWS symbol resolves internally, and the only remaining imports are libc/libc++ functions the DuckDB-Wasm main module genuinely provides.</p><h3>Blocker #3: four missing byte-swap functions</h3><p>Almost. The main module doesn’t export ntohs, htons, ntohl, htonl. Twenty-eight lines of C shims later (wasm32 is little-endian; these are plain byte swaps), the module was self-contained.</p><h3>Does it load?</h3><pre>duckdb-wasm version: v1.5.5<br>LOAD aws: ✅ OK<br>duckdb_extensions(): [{&quot;extension_name&quot;:&quot;aws&quot;,&quot;loaded&quot;:true}]</pre><p>Yes, in Node and in Chrome, against a stock @duckdb/duckdb-wasm build. One gotcha worth knowing: DuckDB refuses extensions built against a different DuckDB version, so the extension must be compiled against exactly the version your duckdb-wasm package wraps.</p><p>And the “doesn’t work anyway” claim? It’s <em>half</em> right. The extension’s reason for existing is the AWS credential chain: environment variables, ~/.aws config files, SSO caches, IMDS, STS. A browser tab has none of those. CHAIN &#39;env&#39; and CHAIN &#39;config&#39; fail cleanly with &quot;no credentials found&quot;. The default chain goes further and tries to open a real socket for the IMDS probe , which dies on a missing _ntohs in duckdb-wasm&#39;s own Emscripten JS glue (a fixable bug on their side, and sockets wouldn&#39;t work in a browser regardless).</p><p>So: compiles, loads, runs, and fails exactly where a browser sandbox says it must. But while testing that, I found the actual treasure.</p><h3>The plot twist: AWS’s endpoints are browser-friendly</h3><p>To exercise the extension I wanted a real workload: <a href="https://aws.amazon.com/s3/features/tables/">Amazon S3 Tables</a>, AWS’s managed Apache Iceberg service, which DuckDB’s iceberg extension can attach as a catalog without need for aws one:</p><pre>CREATE SECRET s3s (TYPE S3, KEY_ID &#39;…&#39;, SECRET &#39;…&#39;, SESSION_TOKEN &#39;…&#39;, REGION &#39;us-east-1&#39;);<br>ATTACH &#39;arn:aws:s3tables:…:bucket/demo&#39; AS s3t (TYPE iceberg, ENDPOINT_TYPE s3_tables);</pre><pre>CREATE SCHEMA IF NOT EXISTS s3t.webdemo;<br>CREATE TABLE s3t.webdemo.sensor_readings (sensor_id VARCHAR, ts TIMESTAMP, temperature DOUBLE, humidity DOUBLE);<br>INSERT INTO s3t.webdemo.sensor_readings SELECT … FROM range(100);<br>SELECT sensor_id, count(*), round(avg(temperature),1)<br>FROM s3t.webdemo.sensor_readings GROUP BY sensor_id;Every one of those statements executes in the browser. The CREATE TABLE and INSERT are real Iceberg commits — catalog metadata via the S3 Tables REST API, parquet files PUT to the bucket, all signed client-side. I verified from native DuckDB afterwards: the 100 rows written by the browser are really there.</pre><h3>The demo</h3><p>I wrapped this in a small React app (<a href="https://medium.com/TODO-your-repo-url">repo link</a>) with three steps:</p><ol><li><strong>Credentials</strong>: paste a temporary key/secret/session token and a table bucket ARN. DuckDB-Wasm boots, loads the locally built aws extension plus the official httpfs and iceberg wasm builds, creates the secret, attaches the catalog. Note that the demo does not need aws extension as it is already supported on latest icebergextensions.</li></ol><p>More on this setup here.</p><p><a href="https://repost.aws/articles/ARenyiop03Ts2bQzG320oa0w/a-quick-startup-guide-to-connect-duckdb-shell-to-amazon-s3-table-bucket">A quick-startup guide to connect DuckDB Shell to Amazon S3 Table Bucket</a></p><ol><li><strong>Generate</strong>: synthesizes IoT sensor readings and commits them to a new Iceberg table in your bucket.</li><li><strong>Query: </strong>preset and free-form SQL against the live table, with timings.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*79m8xxZpA8BtRmd6NvEqkA.png" /><figcaption>Screenshot of the webui</figcaption></figure><p>The repo ships the compiled aws.duckdb_extension.wasm and the full patch set with build instructions, so you can reproduce the binary from source.</p><h3>What should happen upstream</h3><p>I’ve packaged the three fixes as a draft PR against <a href="https://github.com/duckdb/duckdb-aws">duckdb-aws</a> that I will try to upstream soon, with the aws-c-io overlay port needing a small companion change in the extension CI tooling. Honest caveats included: shipping the extension for wasm is only step one.</p><p>The genuinely interesting follow-up belongs in duckdb-wasm itself: a <strong>JS-side credential callback</strong>. Browsers do have credential sources: OAuth flows, Cognito, STS AssumeRoleWithWebIdentity via fetch, they&#39;re just JavaScript-shaped, not file-shaped.</p><p>Let the host application hand credentials to the aws extension and the browser story becomes complete: sign in with your IdP, query your lakehouse, close the tab. Will try to work on this next.</p><h3>Takeaways</h3><ul><li>“Not supported” often decomposes into two or three small, fixable reasons. Here: one missing CMake branch, one missing link flag, four byte-swap functions.</li><li>DuckDB’s wasm extension link step ignores target_link_libraries, if you&#39;re building an extension with native dependencies for wasm, you need LINKED_LIBS.</li><li>AWS quietly made S3 Tables browser-accessible: permissive CORS on both the Iceberg REST catalog and the data plane. Combined with client-side SigV4 in DuckDB’s iceberg extension, serverless-<em>serverless</em> analytics on Iceberg is already real.</li></ul><p><em>Built with DuckDB v1.5.5, duckdb-wasm, emsdk 3.1.71 and vcpkg. All code and patches are developed with help of Fable: </em><a href="https://medium.com/TODO-your-repo-url"><em>repo link</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f44debff81cc" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running DBT over Spark Connect on Glue 6.0 with Iceberg v3 and]]></title>
            <link>https://medium.com/@neuw84/running-dbt-over-spark-connect-on-glue-6-0-with-iceberg-v3-and-67f45946b434?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/67f45946b434</guid>
            <category><![CDATA[apache-iceberg]]></category>
            <category><![CDATA[apache-spark]]></category>
            <category><![CDATA[aws-glue]]></category>
            <category><![CDATA[spark]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Tue, 25 Aug 2026 08:54:05 GMT</pubDate>
            <atom:updated>2026-08-25T09:21:40.349Z</atom:updated>
            <content:encoded><![CDATA[<p>AWS Glue 6.0 interactive sessions now speak <a href="https://docs.aws.amazon.com/glue/latest/dg/interactive-sessions-spark-connect.html">Spark Connect</a>. That single change turns a managed Spark cluster into something a local process can attach to over gRPC, which means the tools that expect to <em>own</em> a SparkSession , dbt among them, suddenly work against Glue without a fork, a plugin, or a bundled jar.</p><p>This also means that you can use the official <a href="https://github.com/dbt-labs/dbt-adapters/tree/main/dbt-spark">Spark dbt</a> connector now.</p><p>This post builds a small but complete example: a dbt project running entirely from a laptop, materialising <strong>Apache Iceberg v3</strong> tables in the Glue Data Catalog, using the <strong>VARIANT</strong> data type to absorb semi-structured IoT payloads whose shape changes per firmware version.</p><p>The code can be found here:</p><p><a href="https://github.com/Neuw84/dbt-glue6">GitHub - Neuw84/dbt-glue6: Demo of Spark Connect with dbt on Glue 6</a></p><h3>Why Spark Connect changes the integration story</h3><p>Before Spark Connect, “run dbt on Glue” meant one of two things. Either you submitted code <em>to</em> Glue and let something on the cluster interpret it, or you used an adapter that reimplemented dbt’s execution against a Glue-specific API. Both approaches put a translation layer between dbt and Spark, and translation layers drift.</p><p>Spark Connect inverts the arrangement. The client holds a SparkSession object that is a thin proxy: it serialises unresolved logical plans over gRPC, the remote driver analyses and executes them, and results come back as Arrow batches. Nothing about the client needs to know it is talking to Glue.</p><p>So the entire dbt integration is one environment variable:</p><pre>env[&quot;SPARK_REMOTE&quot;] = remote_url          # sc://...<br>subprocess.run([sys.executable, &quot;-m&quot;, &quot;dbt.cli.main&quot;, &quot;build&quot;], env=env)</pre><p>with a stock profile:</p><pre>dbt_glue_demo:<br>  target: dev<br>  outputs:<br>    dev:<br>      type: spark<br>      method: session      # dbt asks PySpark for a SparkSession...<br>      host: localhost      # ...unused, but dbt-spark requires the key<br>      schema: dbt_glue_demo<br>      threads: 4</pre><p>dbt-spark&#39;s session method calls SparkSession.builder.getOrCreate(). PySpark sees SPARK_REMOTE and connects to Glue. That is the whole seam — dbt-core 1.12.3 and dbt-spark 1.11.0 straight from PyPI, no patches.</p><p>Remember that you can also use the <a href="https://github.com/aws-samples/dbt-glue">Glue dbt adapter</a> if that suits you.</p><h3>The three-call handshake</h3><p>Getting an sc:// URL out of Glue takes three API calls. There is no long-lived endpoint to hard-code.</p><p><strong>1. Create the session.</strong> SessionType=&quot;SPARK_CONNECT&quot; is the switch:</p><pre>glue.create_session(<br>    Id=&quot;dbt-glue-session&quot;,<br>    Role=session_role_arn,<br>    Command={&quot;Name&quot;: &quot;glueetl&quot;, &quot;PythonVersion&quot;: &quot;3&quot;},<br>    GlueVersion=&quot;6.0&quot;,<br>    SessionType=&quot;SPARK_CONNECT&quot;,<br>    WorkerType=&quot;G.1X&quot;,<br>    NumberOfWorkers=2,<br>    IdleTimeout=30,          # minutes of inactivity before Glue stops it<br>    Timeout=120,             # hard lifetime ceiling<br>    DefaultArguments=default_arguments,<br>)</pre><p><strong>2. Poll until READY.</strong> get_session reports PROVISIONING then READY. In testing this took <strong>about 30 seconds</strong> for a 2× G.1X session. Treat FAILED, STOPPED, TIMEOUT and STOPPING as terminal and stop waiting.</p><p><strong>3. Fetch the endpoint.</strong> This is where the connection string comes from:</p><pre>endpoint = glue.get_session_endpoint(SessionId=session_id)[&quot;SparkConnect&quot;]<br>token = urllib.parse.quote(endpoint[&quot;AuthToken&quot;], safe=&quot;&quot;)<br>remote = f&quot;{endpoint[&#39;Url&#39;]}:443/;use_ssl=true;x-aws-proxy-auth={token}&quot;</pre><p>Three details will bite you if you skip them:</p><ul><li><strong>Append </strong><strong>:443/ yourself.</strong> Url comes back without a port or trailing slash; PySpark&#39;s Spark Connect URL parser needs both.</li><li><strong>URL-encode the token.</strong> It contains characters that would otherwise terminate the ;-delimited parameter list. safe=&quot;&quot; matters.</li><li><strong>The token lives 30 minutes.</strong> AuthTokenExpirationTime says exactly when. The runner therefore mints a <em>fresh</em> token immediately before launching dbt, so the full window is available to the run.</li></ul><p>That 30-minute ceiling is a real constraint, not a formality. dbt holds one SparkSession for the lifetime of the invocation, so a token refreshed mid-run cannot be handed to it. Runs longer than 30 minutes need to be split, or driven by a scheduler that re-establishes the session per batch of models.</p><h3>Configuring Iceberg once, for every model</h3><p>The interesting part of DefaultArguments is the Iceberg catalog. Glue&#39;s convention here is unusual: <strong>all Spark settings go under a single </strong><strong>--conf key</strong>, joined by the literal string &quot; --conf &quot;.</p><pre>default_arguments = {<br>  &quot;--datalake-formats&quot;: &quot;iceberg&quot;,<br>  &quot;--enable-spark-live-ui&quot;: &quot;true&quot;,<br>  &quot;--conf&quot;: &quot; --conf &quot;.join([<br>    &quot;spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions&quot;,<br>    &quot;spark.sql.catalog.glue_catalog=org.apache.iceberg.spark.SparkCatalog&quot;,<br>    &quot;spark.sql.catalog.glue_catalog.catalog-impl=org.apache.iceberg.aws.glue.GlueCatalog&quot;,<br>    &quot;spark.sql.catalog.glue_catalog.io-impl=org.apache.iceberg.aws.s3.S3FileIO&quot;,<br>    f&quot;spark.sql.catalog.glue_catalog.warehouse={warehouse}&quot;,<br>    &quot;spark.sql.catalog.glue_catalog.table-default.format-version=3&quot;,<br>    &quot;spark.sql.defaultCatalog=glue_catalog&quot;,<br>  ]),<br>}</pre><p>The line that does the heavy lifting for this post:</p><pre>spark.sql.catalog.glue_catalog.table-default.format-version=3</pre><p>table-default.* applies to every table the catalog creates. Set it once and <strong>every dbt model comes out as Iceberg v3</strong> with no per-model TBLPROPERTIES. Verified across all four relations in the project — seed, staging, and both marts — each reports format-version=3.</p><p>Doing it at the catalog level rather than per model is deliberate. v3 is a one-way door: a table can be upgraded from v2 to v3, but not downgraded. Making it the catalog default means you decide once, explicitly, in infrastructure code, instead of hoping seventeen models all remembered the same property.</p><p>Setting spark.sql.defaultCatalog=glue_catalog lets dbt&#39;s generated SQL use bare database.table names.</p><h3>VARIANT: one column, many shapes</h3><p>The scenario is IoT device telemetry. Each device sends JSON, and the shape depends on what the device measures and which firmware it runs:</p><pre>{&quot;temp_c&quot;: 21.5, &quot;battery&quot;: {&quot;pct&quot;: 88, &quot;charging&quot;: false}, &quot;tags&quot;: [&quot;indoor&quot;, &quot;floor-1&quot;]}<br>{&quot;temp_c&quot;: 30.1, &quot;humidity_pct&quot;: 55, &quot;fw&quot;: &quot;2.3.1&quot;}<br>{&quot;lat&quot;: 40.4168, &quot;lon&quot;: -3.7038, &quot;speed_kmh&quot;: 12.4}<br>{&quot;error&quot;: {&quot;code&quot;: 503, &quot;msg&quot;: &quot;sensor fault (recalibrated)&quot;}, &quot;battery&quot;: {&quot;pct&quot;: 85, &quot;charging&quot;: true}}<br>{&quot;temp_c&quot;: 19.8, &quot;humidity_pct&quot;: 63, &quot;air_quality&quot;: {&quot;pm25&quot;: 8, &quot;co2_ppm&quot;: 480}, &quot;fw&quot;: &quot;4.0.0&quot;}</pre><p>The seed’s seventeen rows produce <strong>around ten distinct payload shapes</strong>. The classic options are all unpleasant: a sparse wide table that grows a column per firmware release, a STRING column re-parsed on every query, or a schema migration each time a device team ships an update.</p><p>VARIANT is a fourth option. The staging model promotes the raw JSON text into it:</p><pre>select<br>    cast(event_id as bigint)       as event_id,<br>    device_id,<br>    event_type,<br>    cast(ingested_at as timestamp) as ingested_at,<br>    parse_json(payload_json)       as payload<br>from {{ ref(&#39;raw_device_events&#39;) }}</pre><p>parse_json is not a cosmetic cast. The value is stored in Iceberg&#39;s binary VARIANT encoding, and Glue <em>shreds</em> frequently-seen fields into hidden Parquet sub-columns — so filters and projections get column statistics and file pruning instead of a full-text re-parse per row.</p><p>The mart then lifts the fields it cares about into typed columns:</p><pre>select<br>    event_id,<br>    device_id,<br>    event_type,<br>    ingested_at,</pre><pre>    payload:temp_c::double            as temp_c,<br>    payload:humidity_pct::int         as humidity_pct,<br>    payload:speed_kmh::double         as speed_kmh,</pre><pre>    payload:battery.pct::int          as battery_pct,     -- nested object<br>    payload:battery.charging::boolean as battery_charging,</pre><pre>    payload:error.code::int           as error_code,      -- only on error events<br>    payload:error.msg::string         as error_message,</pre><pre>    payload:tags[0]::string           as primary_tag,     -- array element</pre><pre>    payload:fw::string                as firmware,<br>    schema_of_variant(payload)        as payload_shape    -- handy for debugging<br>from {{ ref(&#39;stg_device_events&#39;) }}</pre><p>payload:a.b navigates; ::type casts. A field a given firmware never sends comes back NULL. No CASE pyramid, no coalesce chain, no migration.</p><p>Aggregation works the same way, straight out of the VARIANT with no intermediate flattening:</p><pre>select<br>    device_id,<br>    count(*)                              as event_count,<br>    max(payload:fw::string)               as latest_firmware,<br>    round(avg(payload:temp_c::double), 2) as avg_temp_c,<br>    min(payload:battery.pct::int)         as min_battery_pct,<br>    count(payload:error.code)             as error_count<br>from {{ ref(&#39;stg_device_events&#39;) }}<br>group by device_id</pre><p>avg silently skips rows whose payload lacks the field, which is the desired behaviour: GPS events should not drag the temperature average toward zero.</p><p>Also verified working: variant_get(payload, &#39;$.tags&#39;, &#39;array&lt;string&gt;&#39;) for extracting a whole array with an explicit type, and schema_of_variant for inspecting a row&#39;s inferred shape.</p><h3>The caveat: number inference is per row</h3><p>This one cost real debugging time. schema_of_variant reports the schema Spark infers <strong>for that individual row</strong>, and numeric inference is value-dependent:</p><p>JSON literal Inferred type21.5DECIMAL(3,1)3BIGINT1.0e3DOUBLE&quot;x&quot;STRING</p><p>So the <em>same logical field</em> differs between rows. In this dataset speed_kmh came back as DECIMAL(3,1) for 12.4 but DECIMAL(1,0) for 0.0; lat was DECIMAL(6,4) in one row and DECIMAL(5,3) in another.</p><p>The practical rules:</p><ol><li><strong>Always cast explicitly</strong> when projecting: payload:speed_kmh::double, never the bare extraction. The cast is what gives you a stable column type.</li><li><strong>Never compare </strong><strong>schema_of_variant output across rows</strong> to decide whether two payloads &quot;match&quot;. It is a debugging aid, not a schema registry.</li></ol><h3>Incremental MERGE, and why the write mode matters</h3><p>fct_device_telemetry is incremental, merged on event_id:</p><pre>{{ config(<br>    materialized=&#39;incremental&#39;,<br>    incremental_strategy=&#39;merge&#39;,<br>    unique_key=&#39;event_id&#39;,<br>    tblproperties={<br>        &#39;write.merge.mode&#39;: &#39;merge-on-read&#39;,<br>        &#39;write.update.mode&#39;: &#39;merge-on-read&#39;,<br>        &#39;write.delete.mode&#39;: &#39;merge-on-read&#39;,<br>    }<br>) }}</pre><p>To exercise it, the seed was extended after a first full load: a <strong>correction</strong> to an already-loaded event (same event_id, changed message) plus <strong>new events</strong>, one of which carried a brand-new nested object — air_quality — that no earlier row contained.</p><p>Both landed correctly. The correction updated in place, the new event inserted, and air_quality was absorbed with <strong>zero schema change</strong> — 17 rows, 17 distinct event_ids. A genuinely new nested structure required no DDL, because as far as the table is concerned nothing about the column changed.</p><p><strong>Remember that Iceberg defaults to copy-on-write. Copy on write in this use case is like 8x times slower.</strong></p><h3>Running it</h3><p>Infrastructure is deliberately small: S3 warehouse bucket, Glue Catalog database, the session execution role, and a policy for your local identity. Eleven resources.</p><pre>cd terraform<br>terraform init<br>terraform apply</pre><p>terraform apply also writes generated/glue_session.json, which the runner reads. Infrastructure and runner therefore cannot drift: change the worker type in Terraform and the next dbt run picks it up.</p><pre>cd runner<br>uv run python run_dbt.py build</pre><p>uv handles the local environment. The client pins <strong>pyspark==4.1.1</strong> exactly, to match the server&#39;s 4.1.1-amzn-0 — Spark Connect tolerates some skew, but matching removes a variable. Local Python is pinned to 3.12 rather than the server&#39;s 3.13, purely for dbt dependency wheel availability; the client and server Python versions are independent.</p><p>Result:</p><pre>Completed successfully<br>Done. PASS=17 WARN=0 ERROR=0 SKIP=0 TOTAL=17</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-10vtQQwQ_PZM5oRRbauIw.png" /><figcaption>DBT rendered docs</figcaption></figure><p>Four Iceberg v3 tables and thirteen dbt tests, from a laptop, against a managed Spark cluster.</p><p>The session is left warm between invocations — reattaching to a READY session skips the ~30 s provisioning wait. It auto-stops after IdleTimeout minutes, or immediately with --stop-session.</p><h3>Things to know before you build on this</h3><p><strong>No Lake Formation fine-grained access control.</strong> Spark Connect sessions do not support LF-FGAC. If row/column-level Lake Formation policies are a requirement, this route is not available to you today — use a conventional Glue job.</p><p><strong>Statement APIs do not apply.</strong> RunStatement / GetStatement are for notebook-style sessions. A Spark Connect session is driven entirely through the gRPC endpoint; the statement APIs are not part of the picture.</p><p><strong>Private networking uses a dedicated endpoint.</strong> For a session reachable from a VPC without traversing the internet, the interface endpoint is com.amazonaws.{region}.glue.sessions — distinct from the regular com.amazonaws.{region}.glue endpoint.</p><p><strong>The 30-minute token is the main architectural constraint.</strong> It bounds a single dbt invocation. For longer pipelines, split the work across invocations, each minting its own token, rather than trying to refresh inside a held session.</p><p><strong>One </strong><strong>enableHiveSupport warning is expected.</strong> dbt-spark&#39;s session method calls it, and Spark Connect rejects it with CANNOT_MODIFY_CONFIG. It is harmless — the Iceberg catalog is already the session default.</p><h3>Wrapping up</h3><p>Spark Connect on Glue 6.0 removes the adapter problem rather than solving it. dbt does not know it is talking to Glue; Glue does not know it is running dbt. The contract is a gRPC endpoint and an environment variable, and everything on either side stays stock.</p><p>What that buys, concretely: dbt build from a laptop against managed Spark, in Iceberg v3 tables, with VARIANT columns that absorb per-firmware payload variation and deletion vectors that made incremental merges 8.6× faster against copy on write mode.</p><p>References</p><ul><li><a href="https://docs.aws.amazon.com/glue/latest/dg/interactive-sessions-spark-connect.html">AWS Glue interactive sessions with Spark Connect</a></li><li><a href="https://iceberg.apache.org/spec/">Apache Iceberg table spec v3</a></li><li><a href="https://spark.apache.org/docs/latest/spark-connect-overview.html">Spark Connect overview</a></li><li><a href="https://spark.apache.org/docs/latest/sql-ref-datatypes.html">Spark VARIANT data type</a></li><li><a href="https://docs.getdbt.com/docs/core/connect-data-platform/spark-setup">dbt-spark adapter</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=67f45946b434" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Case of the Dying Executors: Debugging a Native Memory Leak in Apache Iceberg v3]]></title>
            <link>https://medium.com/@neuw84/the-case-of-the-dying-executors-debugging-a-native-memory-leak-in-apache-iceberg-v3-f996ec377d64?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/f996ec377d64</guid>
            <category><![CDATA[apache-iceberg]]></category>
            <category><![CDATA[apache-spark]]></category>
            <category><![CDATA[spark]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Mon, 20 Jul 2026 11:58:38 GMT</pubDate>
            <atom:updated>2026-07-20T11:58:38.581Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How a suspected deletion-vector problem turned out to be a one-line-of-concept leak in a brand-new code path and how an AI coding agent ( Kiro with Fable) and a stubborn insistence on measuring off-heap memory cracked it.</em></p><p>Two years ago I published a benchmark on streaming workloads in a GitHub repository.</p><p><a href="https://github.com/aws-samples/iceberg-streaming-examples">GitHub - aws-samples/iceberg-streaming-examples: This repo contains examples of high throughput ingestion using Apache Spark and Apache Iceberg. These examples cover IoT and CDC scenarios using best practices. The code can be deployed into any Spark compatible engine like Amazon EMR Serverless or AWS Glue. A fully local developer environment is also provided.</a></p><p>I also used the code to push as much as 2 million events per/second while being able to ingest them with MERGE with less than one hour of latency using V2 tables. <strong>Note:</strong> for that 2 million/sec I used a huge EMR Serverless cluster.</p><p>With all the ecosystem evolving to Iceberg Specification V3 I wanted to see how much I could push the numbers now with V3.</p><p>Apache Iceberg format version 3 ships two headline features for row-level workloads:</p><ul><li><strong>Deletion Vectors</strong> (DVs), one compact bitmap per data file instead of piles of position-delete files.</li><li>R<strong>ow Lineage</strong>, which gives every row a stable _row_id. Both are exactly what you want under a high-throughput CDC MERGE.</li></ul><p>I upgraded my streaming benchmark from v2 to v3 and my executors started dying like clockwork while on v2 they were completely stable, DVs were the obvious suspect.</p><h3>The setup</h3><p>Kafka → Spark Structured Streaming → Iceberg, a DMS-style CDC feed doing a hot-key MERGE at ~90,000 rows/second into a bucket(64) merge-on-read mirror table.</p><p>Spark 4.0.2 on EKS, Iceberg 1.11, six executors with 8 cores, 32 GB of heap and 8 GB of overhead each and 40 GiB pod limits.</p><p>On format version 2, this runs all day. On format version 3, every run followed the same script:</p><ul><li><strong>t+75 minutes</strong>: the first wave of executors is OOM-killed by the kernel.</li><li>Spark spins up replacements. They die within <strong>5–7 minutes</strong>.</li><li>The driver follows shortly after.</li></ul><p>Same code, same knobs, same feed. The only variable was format-version=3. I ran the v2 control for <strong>6 hours and 46 minutes with a memory line flat as glass</strong> to be sure.</p><p>So: <strong>something v3-specific, growing over time, unbounded was killing the benchmark.</strong></p><h3>Act 1: chasing the obvious suspect</h3><p>v3’s MERGE path <em>is</em> structurally different. With DVs, a row-level MERGE becomes a read-modify-write: previous deletion vectors are loaded, merged with new deletes, and rewritten every batch. Reading the Spark Iceberg write path (SparkPositionDeltaWrite), two genuinely suspicious terms fell out of code review with Fable ( and careful after double from me).</p><ol><li><strong>Driver side</strong>: every batch broadcasts a map of <em>every data file that has deletes → its delete files</em>, with full metadata (partition data, column stats and bounds included). On a growing table with a hot-key MERGE this converges to “all files in the table” and grows monotonically.</li><li><strong>Executor side</strong>: BaseDVFileWriter buffers one in-memory bitmap per touched data file for the whole task and only merges each file&#39;s previous DV at close() — all of it resident simultaneously. Peak state is O(files touched by the task).</li></ol><p>Both are real. I fixed both with Fable’s help:</p><ul><li><em>Slim-broadcast</em> patch that strips the metadata executors never read (measurably 1.4–1.8× smaller serialized broadcasts).</li><li><em>Clustered</em> DV writer that exploits Spark’s sorted-input mode to hold exactly one file’s bitmap at a time.</li></ul><ul><li><a href="https://github.com/apache/iceberg/pull/17281">Spark: Broadcast slim rewritable delete metadata in position delta wr... by Neuw84 · Pull Request #17281 · apache/iceberg</a></li><li><a href="https://github.com/apache/iceberg/pull/17287">Core, Spark: Add clustered DV writer for ordered position delta writes by Neuw84 · Pull Request #17287 · apache/iceberg</a></li></ul><p>Then, I did the honest thing and ran a <strong>three-arm A/B</strong> at identical sizing, same feed, one variable per arm.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/469/1*rnttp7FsJxo-MsMOvtStng.png" /><figcaption>Tests for clustered writer ( note that I did the same with driver side fix)</figcaption></figure><p>Ouch. The sorted-input mode (fanout=false) bought fifteen minutes. Our shiny bounded-memory writer bought <strong>three more minutes </strong>(inside run noise). Executor working sets in both arms climbed the <em>same</em> curve, ~50-80 MiB/min, straight into the 40 GiB pod limit.</p><p>Meanwhile the most telling metric was quietly sitting in the Spark REST API the whole time: <strong>peak JVM heap was frozen</strong> at ~28–29 GB in every arm, for hours, to the megabyte.</p><p>The heap wasn’t growing. The <em>pod</em> was. Whatever was eating memory lived outside the JVM heap, and I had just spent 3 days optimizing heap-side data structures. Nervertheless, this is an architectural improvement, O(1 file) instead of O(files touched), that matters for file-heavy tables and tight heaps.</p><p><strong>Note on AI : </strong>without Fable´s help this could be a work of weeks. Fable helped me out to run it on top of EKS in distributed manner ( I discovered the bug on EMR Serverless but needed OSS confirmation) for quicker A/B testing, also developed the monitoring scripts that allowed to conclude that on-heap code wasn´t the problem. If you look at the code from those pull requests you can see that there isn´t much but they are a worthy add and the code is clean ( note that I have a long time Java background I and I am already familiar with Spark and Iceberg code that helps on guidance).</p><h3>Act 2: stop guessing, measure the off-heap</h3><p>This was the turning point, and it was a methodology decision, not a code insight: no more hypotheses until we could <em>name</em> every byte in the container. The plan:</p><p><strong>1. Ask the kernel first.</strong> Before killing a doomed run, we exec’d into an executor pod sitting 500 MiB from its limit and read cgroup v2’s memory.stat:</p><pre>anon 42514927616     # 42.5 GB of anonymous memory<br>file 285040640       # 285 MB of page cache</pre><p>That single read eliminated half the hypothesis space. This was <strong>not page cache</strong> from the Puffin/Parquet churn (which the kernel could reclaim under pressure): it was real, allocated, unreclaimable memory. The JVM process RSS was 41.8 GB against a 32 GB heap: <strong>~10 GB of native memory</strong>, and growing.</p><p><strong>2. Make the JVM confess.</strong> Note that Java have very powerful configuration options, I relaunched with -XX:NativeMemoryTracking=summary and a monitor that sampled jcmd VM.native_memory plus memory.stat from every executor every two minutes. The time series was unambiguous:</p><pre>workingSet   anon    NMT &quot;Other&quot; (direct buffers)<br>t+05min       33.7 GiB   33.5      157 MB<br>t+20min       35.0       34.7      337 MB<br>t+40min       36.1       35.8    1,229 MB<br>t+60min       37.4       37.1    2,638 MB<br>t+80min       38.8       38.4    4,010 MB<br>t+87min       39.9       39.8    5,446 MB   ← pod killed</pre><p>Every other NMT category , heap, GC, metaspace, code cache, threads was flat for the entire run.</p><p>The killer was <strong>direct ByteBuffers</strong>, accumulating at ~60 MB/min and accelerating with table size. But, what was causing this?</p><p><strong>Note on AI: </strong>as long time Java developer ( used to do profiling back to Netbeans 5 era), I knew how powerful Java monitor is, however, Fable and Kiro helped me a lot of doing the setup and running the experiments that would cost me days ( getting configs, automating EKS A/B runs, monitoring scripts).</p><p><strong>3. Set a trap.</strong> Direct buffers are only released when their Java wrapper objects get garbage-collected, and a 32 GB heap that never fills rarely runs old-gen collections. So were the buffers <em>garbage</em> (waiting for a GC that never came, a config fix) or <em>leaked</em> (still reachable bug)? One flag distinguishes them: -XX:MaxDirectMemorySize=4g.</p><p>When the JDK hits that cap it forces a full GC and retries <strong>before</strong> throwing. If the run stabilizes into a sawtooth: garbage. If it throws: leak, and the stack trace names the allocator. Seventy-eight minutes later, all six executors answered within seconds of each other:</p><pre>java.lang.OutOfMemoryError: Cannot reserve 4194304 bytes of direct buffer memory<br>    (allocated: 4292201200, limit: 4294967296)<br>  at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:332)<br>  at ...shaded.io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:737)<br>  at ...shaded.org.apache.arrow.vector.BaseFixedWidthVector.allocateNew(...)<br>  at org.apache.iceberg.arrow.vectorized.VectorizedArrowReader.allocateBigIntVector(...)<br>  at org.apache.iceberg.arrow.vectorized.VectorizedArrowReader$RowIdVectorReader.read(...)<br>  at org.apache.iceberg.spark.data.vectorized.ColumnarBatchReader.read(...)</pre><p>A <strong>true leak</strong>, the forced GC could not reclaim the buffers — and a named culprit that made everything click at once: <strong>RowIdVectorReader</strong>. The reader that materializes the v3 <strong>row lineage</strong> _row_id metadata column.</p><p>Not the deletion vectors. Not the MERGE writer. The <em>read side</em> of v3’s other new feature, a code path that v2 tables never instantiate. Which is why the v2 control ran flat for seven hours: it was never about how we wrote deletes at all.</p><p><strong>Note on AI: </strong>note that I already knew that I could cap to see what was going on but with Kiro and Fable´s help I run these experiments in an afternoon!</p><h3>Act 3: the bug, in fifteen lines</h3><p>The offending pattern (Iceberg’s iceberg-arrow module, paraphrased):</p><pre>@Override<br>public VectorHolder read(VectorHolder reuse, int numValsToRead) {<br>  ...<br>  // fresh direct-memory vector allocated EVERY batch; `reuse` is ignored<br>  BigIntVector rowIds = allocateBigIntVector(ROW_ID_ARROW_FIELD, numValsToRead);<br>  ...<br>  return VectorHolder.vectorHolder(rowIds, MetadataColumns.ROW_ID, nulls);<br>}</pre><pre>@Override<br>public void close() {<br>  // don&#39;t close result vectors as they are not owned by readers<br>}</pre><p>The reader allocates a fresh Arrow BigIntVector (backed by netty pooled direct memory, 4 MiB arena chunks, exactly the allocation size in the OOM) for every column batch, ignores the reuse parameter that exists for precisely this purpose, keeps no reference to what it handed out, and closes nothing.</p><p>Nothing downstream closes the holder&#39;s vector either. One leaked vector per batch, per lineage column, forever. Its sibling LastUpdatedSeqVectorReader (_last_updated_sequence_number) has the identical pattern.</p><p>The fix is exactly what the base class already does for regular columns: the reader owns its result vector, allocate lazily, reuse across batches while capacity suffices, release in close().</p><p>Plus regression tests that assert the Arrow root allocator returns to its baseline after close, tests that fail against the unfixed code.</p><p><a href="https://github.com/apache/iceberg/pull/17296">Arrow: Fix direct memory leak in row lineage vectorized readers by Neuw84 · Pull Request #17296 · apache/iceberg</a></p><p><strong>Note on AI: </strong>Kiro with Fable implemented the fix with some tests, note that the code is quite simple to understand if you are familiar with Java code. That is a plus in times where review is the bottleneck for AI generated code. Note that implementing this, along test would take it me like two or three days of work.</p><h3>Act 4: proof</h3><p>Same cluster, same feed, same sizing, no tuning flags ,only the reader fix.</p><p><strong>Thirteen hours of dead-flat memory against</strong> a 92-minute baseline death. v3 streaming MERGE at 90k rows/s is simply <em>stable</em> now,no extra overhead, no direct-memory cap, no compaction crutch.</p><p>You still want compaction for read amplification as batch times grow as uncompacted DVs pile up, but that’s now a throughput concern.</p><h3>What working with an AI agent was actually like</h3><p>I did this investigation pair-programming with an AI coding agent (Kiro) and with Fable, and it’s worth being precise about the division of labor, because it wasn’t “AI finds bug”.</p><p><strong>What the agent did well</strong>: volume and velocity. It read the unfamiliar Iceberg write path and produced the two initial hypotheses in minutes. It wrote every monitoring script (pod-state watchers, NMT samplers, log collectors with rotation-proof capture), drove the build-image-deploy-run loop dozens of times, kept multi-hour experiments under observation, extracted trend tables from raw logs, wrote the patches, the regression tests, and the microbenchmark. When the OOM stacks landed at 2 a.m. cluster time, the collector it had written twenty minutes earlier had already saved them with full context. It was also honest under pressure: when the three-arm A/B showed our own writer bought nothing, the summary said so in the first sentence.</p><p><strong>What the human (me) did</strong>: strategy and skepticism. Deciding that a v2 control run mattered before any code changed. Refusing to accept “memory grows” without an anon-vs-file-cache split. Insisting we instrument off-heap when the heap numbers looked <em>too</em> clean. Choosing the direct-memory cap as a trap rather than a mitigation. And repeatedly asking the uncomfortable question:<em>“but did our fix actually change anything?” </em>that turned two plausible optimizations into footnotes and kept the investigation moving toward the real bug.</p><p>The agent multiplied the experiments per day; the human decided which experiments deserved to exist. Neither half finds this bug alone in a week ( as personal note, this would took at least 2 weeks of full time job whereas I could manage to discover and fix it in 3 days.</p><h3>The runbook, if your executors are dying</h3><ol><li><strong>Run the control.</strong> One variable. If v2-vs-v3 (or old-vs-new anything) flips the behavior, you’ve halved the search space before touching a profiler.</li><li><strong>Watch peak JVM heap vs container working set.</strong> If heap is flat and the pod climbs, stop optimizing your data structures, the answer is off-heap.</li><li><strong>memory.stat before the pod dies.</strong> anon vs file is one kubectl exec and instantly separates &quot;real allocation&quot; from &quot;reclaimable cache&quot;.</li><li><strong>NMT is cheap.</strong> -XX:NativeMemoryTracking=summary plus jcmd sampling attributes the anon growth: heap, GC, threads, code or Other, which means direct buffers.</li><li><strong>Cap direct memory to force a confession.</strong> -XX:MaxDirectMemorySize makes the JDK full-GC-and-retry before throwing. Sawtooth = garbage, config problem. OOME = leak, and the stack trace is your suspect, named and addressed.</li><li><strong>Validate with the same telemetry that caught the bug.</strong> Flat lines for thirteen hours are worth more than any argument.</li></ol><p>Everything is on its way to be upstreamed, hopefully soon :).</p><p>V3’s features are worth it. Now they’re also stable at 90k rows a second.</p><p><em>The investigation, experiments and patches described here were done in collaboration with an AI coding agent; all findings were verified against live cluster telemetry, and all code was reviewed and tested before submission upstream.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f996ec377d64" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running dbt on Amazon EMR Serverless with Spark Connect and S3 Tables]]></title>
            <link>https://medium.com/@neuw84/running-dbt-on-amazon-emr-serverless-with-spark-connect-and-s3-tables-41d1b5a94e27?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/41d1b5a94e27</guid>
            <category><![CDATA[emr]]></category>
            <category><![CDATA[apache-iceberg]]></category>
            <category><![CDATA[dbt]]></category>
            <category><![CDATA[s3-tables]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Wed, 08 Jul 2026 10:40:32 GMT</pubDate>
            <atom:updated>2026-07-08T15:54:33.034Z</atom:updated>
            <content:encoded><![CDATA[<p>For years, the pitch for dbt was simple: write SQL, get a warehouse-native transformation framework. The catch was always the warehouse. If your data lived in a lake instead of Snowflake or BigQuery, running dbt meant standing up and babysitting a Spark cluster.</p><p>That story is changing. With <strong>Apache Spark Connect</strong> now available on <strong>Amazon EMR Serverless</strong>, and <strong>Amazon S3 Tables</strong> giving you managed Apache Iceberg with zero table maintenance, you can point dbt at a serverless Spark endpoint from your laptop and transform lakehouse data as if it were any other warehouse. No cluster to size, no cluster to forget to turn off.</p><p>This post walks through a complete, Terraform-managed example that does exactly that. The same dbt code runs from your laptop for development and on Amazon MWAA (managed Airflow) for scheduling. Everything is serverless, and the whole thing tears down with a single terraform destroy.</p><p>The repo where the code lives is:</p><p><a href="https://github.com/aws-samples/sample-dbt-spark-connect-emr-serverless-s3tables">GitHub - aws-samples/sample-dbt-spark-connect-emr-serverless-s3tables</a></p><p>I’ll focus on the parts that were genuinely interesting to get right, because a few of them are not in the happy-path docs yet.</p><p>This blog post is based from some previous ideas from some colleages:</p><p><a href="https://aws.amazon.com/blogs/big-data/serverless-analytics-pipelines-using-the-apache-spark-engine-in-amazon-athena/">https://aws.amazon.com/blogs/big-data/serverless-analytics-pipelines-using-the-apache-spark-engine-in-amazon-athena/</a></p><p><a href="https://aws.amazon.com/blogs/big-data/announcing-spark-connect-on-amazon-emr-serverless-interactive-pyspark-development-anywhere/">https://aws.amazon.com/blogs/big-data/announcing-spark-connect-on-amazon-emr-serverless-interactive-pyspark-development-anywhere/</a></p><h3>The shape of the thing</h3><p>Here’s the architecture:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2Kl_VNMTqpI76Ih9Dd8Qkw.png" /></figure><ol><li>A small Python runner calls the EMR Serverless session APIs to start an interactive session and fetch an endpoint hostname plus a short-lived auth token.</li><li>It exports SPARK_REMOTE, and the stock dbt-spark session method connects over Spark Connect (a sc:// URL, gRPC over TLS on port 443) and runs your models. The remote Spark driver reads and writes S3 Tables using the EMR Serverless execution role. (From a laptop that gRPC goes to the public endpoint; from an MWAA worker it goes through a VPC endpoint — more on that later.)</li></ol><p>The dbt project itself is a classic jaffle-shop layout — raw seeds, staging models, and marts — but every table is Iceberg living in S3 Tables.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vqrk65-r9sPVS5k_BlinDA.png" /><figcaption>MWAA showing both DAGs</figcaption></figure><h3>The star of the show: SPARK_REMOTE and the stock adapter</h3><p><a href="https://spark.apache.org/docs/latest/spark-connect-overview.html">Spark Connect</a> decouples your client from the Spark driver. Your laptop sends the logical plan; a remote driver executes it. EMR Serverless <a href="https://docs.aws.amazon.com/emr/latest/EMR-Serverless-UserGuide/spark-connect.html">exposes this as managed interactive sessions</a>: you call an API, it provisions a driver and executors, and hands you back an endpoint and a short-lived (~1 hour) token that you present in a header called x-aws-proxy-auth. The connection URL ends up looking like this:</p><pre>sc://&lt;host&gt;:443/;use_ssl=true;x-aws-proxy-auth=&lt;token&gt;</pre><p>PySpark’s SparkSession.builder.getOrCreate() honors an environment variable, <strong>SPARK_REMOTE</strong> — set it to that sc:// URL and PySpark opens a <em>remote</em> Spark Connect session. And dbt-spark&#39;s plain session method is nothing more than getOrCreate(). So the released<strong> </strong><strong>dbt-spark[session]</strong> adapter connects to EMR Serverless. This is documented in <a href="https://docs.aws.amazon.com/emr/latest/EMR-Serverless-UserGuide/tutorials-dbt.html">Using dbt-core with EMR Serverless</a>, the profile used in dbt should like this:</p><pre># dbt/profiles.yml<br>emr_serverless:<br>  target: dev<br>  outputs:<br>    dev:<br>      type: spark<br>      method: session<br>      host: NA          # required by the schema, ignored when SPARK_REMOTE is set<br>      schema: &quot;{{ env_var(&#39;DBT_SPARK_SCHEMA&#39;, &#39;analytics&#39;) }}&quot;</pre><p>Who sets SPARK_REMOTE? A small runner that owns the session lifecycle:</p><pre># runner/run_dbt.py (abridged)<br>session = EmrServerlessSparkConnect(application_id, region)<br>session.ensure_application_started()<br>session.start_session(execution_role_arn, runtime_configuration=spark_conf)<br>session.wait_until_ready()<br>endpoint = session.get_endpoint()<br>env[&quot;SPARK_REMOTE&quot;]     = endpoint.spark_remote_url   # sc://...;x-aws-proxy-auth=&lt;token&gt;<br>env[&quot;DBT_SPARK_SCHEMA&quot;] = schema<br>subprocess.run([dbt, *dbt_command, &quot;--project-dir&quot;, project_dir], env=env)<br># ... finally: session.terminate()</pre><p>Start a session, export the endpoint, run dbt, and terminate so the meter stops. Because the token lasts about an hour, each invocation gets a fresh session — perfectly fine for interactive development and scheduled batch runs alike.</p><p>Two footnotes worth internalizing. First, the client’s PySpark version must match the remote Spark version (4.0.2 on emr-spark-8.0.0), so we pin pyspark[connect]==4.0.2 — a version mismatch is the classic Spark Connect footgun. Second, the session method calls enableHiveSupport(), which Spark Connect politely refuses (CANNOT_MODIFY_CONFIG) — a harmless warning, since our S3 Tables catalog is a named default catalog configured on the remote session, not the Hive one.</p><h3>S3 Tables, and the CTAS gotcha that shaped the whole design</h3><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables.html">Amazon S3 Tables</a> is a purpose-built S3 bucket type that stores Apache Iceberg tables and handles compaction and snapshot cleanup for you. It’s an ideal dbt target: dbt creates and mutates tables, S3 Tables keeps them healthy.</p><p>There are two ways to connect Spark to S3 Tables, and choosing between them was the single most consequential decision in this build.</p><p><strong>Option A — the Iceberg REST endpoint.</strong> S3 Tables ships a standard Iceberg REST catalog at https://s3tables.&lt;region&gt;.amazonaws.com/iceberg, authenticated with SigV4. It&#39;s clean and needs no extra JARs. But it has a limitation buried in the <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-integrating-open-source.html">docs</a>: it does <strong>not</strong> support CREATE TABLE AS SELECT yet.</p><p>That single line is a dealbreaker for dbt. dbt’s default table materialization <em>is</em> a CTAS. Point dbt at the REST catalog and your first dbt run fails.</p><p><strong>Option B — the S3 Tables Catalog for Apache Iceberg client.</strong> This catalog implementation (software.amazon.s3tables.iceberg.S3TablesCatalog) talks to the S3 Tables APIs directly and <em>does</em> support CTAS. So that&#39;s what the example uses. The catalog config is injected into every session as Spark runtime configuration:</p><pre># rendered into the session&#39;s spark-defaults<br>spark.sql.catalog.s3tablesbucket             = org.apache.iceberg.spark.SparkCatalog<br>spark.sql.catalog.s3tablesbucket.catalog-impl = software.amazon.s3tables.iceberg.S3TablesCatalog<br>spark.sql.catalog.s3tablesbucket.warehouse   = arn:aws:s3tables:&lt;region&gt;:&lt;acct&gt;:bucket/&lt;name&gt;<br>spark.sql.defaultCatalog                     = s3tablesbucket<br>spark.sql.extensions                         = org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions</pre><p>Setting defaultCatalog means dbt&#39;s plain schema.table references resolve straight into S3 Tables — dbt doesn&#39;t even need to know it&#39;s talking to a special catalog.</p><p>There’s a second S3 Tables quirk that only bites on the <em>second</em> run. S3 Tables refuses to drop a table <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-delete.html?utm_source=chatgpt.com">without purge </a>(dropTable(purge=false) → error), but dbt&#39;s default drop — used by seed full-refresh and table replacement — is a plain DROP TABLE IF EXISTS. So the first dbt build sails through on an empty namespace, and the next one fails trying to replace what&#39;s already there. The fix is a one-macro override that makes table drops use PURGE:</p><pre>-- dbt/macros/drop_relation.sql<br>{% macro spark__drop_relation(relation) -%}<br>  {%- if relation.type == &#39;table&#39; -%}<br>    {% call statement(&#39;drop_relation&#39;, auto_begin=False) -%}<br>      drop table if exists {{ relation }} purge<br>    {%- endcall %}<br>  {%- else -%}<br>    {% call statement(&#39;drop_relation&#39;, auto_begin=False) -%}<br>      drop {{ relation.type }} if exists {{ relation }}<br>    {%- endcall %}<br>  {%- endif -%}<br>{%- endmacro %}</pre><p>Spark 4.0.2 honors the PURGE, and re-runs become idempotent. If you only ever test the happy path once, you&#39;ll ship this bug.</p><p>The lesson: when you’re wiring a SQL tool to a catalog, check whether CTAS, MERGE, and <em>drops</em> behave the way your tool assumes , and always run your pipeline twice.</p><h3>Reaching Iceberg’s table features from a dbt config()</h3><p>Once dbt is talking to S3 Tables, the interesting question becomes: how much of Iceberg can you drive from a model, without writing custom macros? More than you’d expect. dbt-spark forwards config keys straight into the SQL it generates — partition_by becomes PARTITIONED BY (...), tblproperties becomes TBLPROPERTIES (...), and incremental_predicates gets ANDed into the merge ON clause. That&#39;s enough to reach hidden partitioning, per-column statistics, and partition-pruned merges. Here&#39;s the fct_orders model:</p><pre>{{<br>    config(<br>        materialized=&quot;incremental&quot;,<br>        incremental_strategy=&quot;merge&quot;,<br>        unique_key=&quot;order_id&quot;,<br>        file_format=&quot;iceberg&quot;,<br>        partition_by=&quot;days(order_date)&quot;,<br>        incremental_predicates=[<br>            &quot;DBT_INTERNAL_DEST.order_date &gt;= date_add(current_date(), -3)&quot;<br>        ],<br>        tblproperties={<br>            &quot;write.metadata.metrics.default&quot;: &quot;counts&quot;,<br>            &quot;write.metadata.metrics.column.amount&quot;: &quot;full&quot;,<br>            &quot;write.metadata.metrics.column.status&quot;: &quot;full&quot;<br>        }<br>    )<br>}}</pre><p><strong>partition_by is a partition transform, not a column.</strong> Because this is Iceberg, days(order_date) is <em>hidden</em> partitioning: Iceberg derives a partition value from order_date and prunes on it automatically, so a query that filters where order_date between ... gets pruning without anyone adding an order_date_day column or writing WHERE partition_col = .... The full transform vocabulary is available — years(), months(), days(), hours(), bucket(N, col), truncate(N, col) — and because the spec is hidden you can evolve it later without rewriting history. The one judgment call is granularity: days() on twenty rows is overkill (you get one tiny file per day), so reach for months() or years() on sparse data and save days()/hours() for real volume.</p><p><strong>tblproperties is where per-column statistics live.</strong> The write.metadata.metrics.* keys tell Iceberg how much column-level metadata to record in each manifest — lower/upper bounds and value/null counts — which the planner then uses for scan pruning and predicate pushdown. Setting default = counts keeps every column cheap, while full on amount and status collects bounds for the columns you actually filter and aggregate on. It&#39;s the same dict-to-TBLPROPERTIES bridge, so any Iceberg write property (compression codec, target file size, and so on) is reachable the same way.</p><p><strong>incremental_predicates is the merge&#39;s </strong><strong>ON-clause performance lever.</strong> By default dbt builds the merge condition from unique_key alone — DBT_INTERNAL_SOURCE.order_id = DBT_INTERNAL_DEST.order_id — which forces Iceberg to scan <em>every</em> partition of the target looking for matching order_ids. On a large table that&#39;s the whole cost of the run. dbt-spark ANDs incremental_predicates into that same ON clause, so adding a bound on the partition column changes the generated SQL to:</p><pre>merge into analytics.fct_orders as DBT_INTERNAL_DEST<br>    using fct_orders__dbt_tmp as DBT_INTERNAL_SOURCE<br>    on DBT_INTERNAL_DEST.order_date &gt;= date_add(current_date(), -3)<br>           and DBT_INTERNAL_SOURCE.order_id = DBT_INTERNAL_DEST.order_id<br>    when matched then update set *<br>    when not matched then insert *</pre><p>Now Iceberg prunes on order_date and only touches the last few day-partitions — exactly the win you want on a partitioned table. There&#39;s a sharp edge, though, and it&#39;s worth stating plainly: a predicate in ON doesn&#39;t just filter, it <em>gates matching</em>. If a source row&#39;s key lives in the target but in a partition outside the window, it won&#39;t match and gets inserted as a duplicate. The window has to cover every partition where an update can legitimately land. It&#39;s safe in this model because the incremental filter only ever feeds in orders newer than the current max order_date, and the unique test on order_id will scream if that assumption is ever wrong. Widen it per run with --vars &#39;{merge_lookback_days: 30}&#39;.</p><p>You don’t have to take the DDL on faith, you can run these iceberg queries that let you inspect the tables:</p><pre>DESCRIBE TABLE EXTENDED s3tablesbucket.analytics.fct_orders;<br>-- # Partitioning<br>-- Part 0   days(order_date)<br><br>SHOW TBLPROPERTIES s3tablesbucket.analytics.fct_orders;<br>-- write.metadata.metrics.default          counts<br>-- write.metadata.metrics.column.amount    full<br>-- write.metadata.metrics.column.status    full<br><br>SELECT * FROM s3tablesbucket.analytics.fct_orders.partitions;<br>-- one row per order_date_day, with record_count / file_count / size</pre><p>One thing to remember operationally, and it’s a <em>dbt</em> limitation rather than an Iceberg one: changing partition_by on a table that already exists is a no-op on a normal incremental run. Iceberg itself supports partition <em>evolution</em> in place — ALTER TABLE ... ADD PARTITION FIELD rewrites no data (old files keep their spec, new writes use the new one) — but dbt-spark only emits the partition spec in the CREATE TABLE DDL, which runs on the first build and on --full-refresh. An incremental run just MERGEs into whatever spec the table already has; it never diffs partition_by against the live table. So --full-refresh is dbt&#39;s built-in way to apply a new spec — with the irony that it does so by dropping and recreating the table, a full rewrite, which is the opposite of Iceberg&#39;s zero-rewrite evolution. If you want the in-place version, run the ALTER TABLE ... ADD/DROP PARTITION FIELD yourself (a dbt run-operation or a one-off statement) and dbt&#39;s subsequent MERGEs will write new data under the new spec automatically.</p><blockquote>Note that this may change on future dbt versions.</blockquote><h3>Terraform can’t enable Spark Connect sessions (yet)</h3><p>To accept Spark Connect clients, an EMR Serverless application needs interactiveConfiguration.sessionEnabled = true. That field exists in CloudFormation and the AWS API — but at the time of writing, neither the aws nor the awscc Terraform provider exposes it. The aws provider&#39;s interactive_configuration block only knows about the Livy and Studio toggles.</p><p>The pragmatic fix: create the application with Terraform, then flip the flag with the AWS CLI in a null_resource. UpdateApplication is a partial update and works while the freshly created app is stopped.</p><pre>resource &quot;null_resource&quot; &quot;enable_spark_connect_sessions&quot; {<br>  triggers = { application_id = aws_emrserverless_application.this.id }<br>  provisioner &quot;local-exec&quot; {<br>    command = &lt;&lt;-EOT<br>      aws emr-serverless update-application \<br>        --application-id &quot;${aws_emrserverless_application.this.id}&quot; \<br>        --interactive-configuration &#39;{&quot;sessionEnabled&quot;:true}&#39;<br>    EOT<br>  }<br>}</pre><p>It&#39;s a small thing, but it means the AWS CLI has to be present during terraform apply. Worth documenting loudly so nobody&#39;s surprised. There&#39;s a sharper edge, too: because the provider doesn&#39;t own that field, it <em>wipes</em> the interactive configuration whenever it updates the application for any other reason — silently turning Spark Connect off until you notice a Sessions are not enabled error. The fix is to re-assert the flag on every apply (a fast no-op when it&#39;s already set), so a stray terraform apply can&#39;t quietly break your sessions.</p><blockquote>Note that this may change on future terraform aws provider versions.</blockquote><h3>The dbt project</h3><p>The models are deliberately familiar so the focus stays on the platform:</p><ul><li><strong>Seeds</strong> (raw_customers, raw_orders, raw_payments) become the raw Iceberg tables on S3 Tables — loaded through the EMR Serverless session itself.</li><li><strong>Staging</strong> models are <em>ephemeral</em>: they compile to CTEs and create no database objects, which sidesteps questions about Iceberg view support and keeps the namespace tidy.</li><li><strong>Marts</strong> are real Iceberg tables. dim_customers is a straightforward table; fct_orders is <strong>incremental</strong> and shows off Iceberg&#39;s row-level MERGE:</li></ul><pre>{{ config(<br>    materialized=&quot;incremental&quot;,<br>    incremental_strategy=&quot;merge&quot;,<br>    unique_key=&quot;order_id&quot;,<br>    file_format=&quot;iceberg&quot;,<br>) }}</pre><pre>select o.order_id, o.customer_id, o.order_date, o.status,<br>       coalesce(op.amount, 0) as amount<br>from orders o<br>left join order_payments op on o.order_id = op.order_id<br>{% if is_incremental() %}<br>  where o.order_date &gt; (select coalesce(max(order_date), date &#39;1900-01-01&#39;) from {{ this }})<br>{% endif %}</pre><p>Because the whole thing is Spark SQL under the hood, you get Spark 4’s niceties — ANSI mode, the VARIANT type and Iceberg v3 .</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*13DoRaG0BRfwrTqfd3Mn2Q.png" /><figcaption>Logs of DBT build on MWAA web interface showing models and tests</figcaption></figure><h3>Sharing the lineage: a password-protected docs site</h3><p>A transformation project is only as useful as it is legible to the rest of the team. dbt already solves this: dbt docs generate produces a browsable site with a <strong>model lineage graph</strong> and a full <strong>data catalog</strong> — columns, types, descriptions, and stats pulled live from the warehouse. The question is how to share it without standing up a web server or leaving it open to the world.</p><p>Because dbt docs generate queries the catalog, it runs over the same Spark Connect session as everything else. The --static flag bundles the whole thing into one self-contained HTML file, which is perfect for static hosting. The runner&#39;s --publish-docs flag does the generate-and-upload in one step:</p><pre>uv run python run_dbt.py --publish-docs --minimal -- docs generate --static<br># generates dbt/target/static_index.html (lineage + catalog inlined) and<br># uploads it to s3://&lt;docs_bucket&gt;/index.html</pre><p>The --minimal flag is worth a word: dbt docs generate only introspects the catalog, so the session doesn&#39;t need the compute a dbt build does. It shrinks the session&#39;s Spark workers to small 2 vCPU driver and executors (half the 4 vCPU default), which keeps the footprint well under the application&#39;s maximumCapacity — so a docs refresh is far less likely to trip ServiceQuotaExceededException when it overlaps a running build session.</p><p>To publish it, the example uses a tidy little serverless stack: a <strong>private</strong> S3 bucket holds the HTML, and <strong>CloudFront</strong> serves it through an <strong>Origin Access Control</strong> so the bucket itself is never public. The password gate is a <strong>CloudFront Function</strong> on the viewer-request event — a few lines of JavaScript that run at every edge location and reject anything without the right HTTP Basic Auth header:</p><pre>function handler(event) {<br>    var request = event.request;<br>    var expected = &quot;Basic &lt;base64(user:password)&gt;&quot;; // injected by Terraform<br>    if (!request.headers.authorization || request.headers.authorization.value !== expected) {<br>        return {<br>            statusCode: 401,<br>            statusDescription: &quot;Unauthorized&quot;,<br>            headers: { &quot;www-authenticate&quot;: { value: &quot;Basic realm=\&quot;dbt docs\&quot;&quot; } }<br>        };<br>    }<br>    return request;<br>}</pre><p>I reached for CloudFront Functions rather than Lambda@Edge on purpose: they run at all edges with no us-east-1 deployment dance, they&#39;re billed in fractions of a cent per million invocations, and Basic Auth is about as much logic as they need. Terraform precomputes the base64(&quot;user:password&quot;) string, embeds it in the function, and can auto-generate a strong password that it surfaces as a sensitive output. Prefer to gate by IP instead, Swap the header check for a request.clientIp test or bolt an AWS WAF IP set onto the distribution, same shape, different predicate.</p><p>One honest caveat: since the credential is baked into the (readable) function code, this is “keep casual visitors out”, not a vault. On produciton you may want to keep this on private network.</p><p><strong>Keeping it fresh.</strong> Docs are only useful if they track the project, so a second MWAA DAG — dbt_docs_refresh — regenerates and republishes the site on a daily schedule (and on demand, from the Airflow UI). It&#39;s the same run_dbt.py --publish-docs under the hood, so there&#39;s one code path for laptop and scheduler.</p><p>One bootstrapping wrinkle is worth calling out: on a brand-new stack the tables don&#39;t exist yet, so there&#39;s no catalog to render — but the CloudFront/S3 wiring still needs <em>something</em> to serve, and terraform apply would otherwise fail on the missing file. So Terraform seeds a tiny <strong>placeholder</strong> page on the first apply and marks the docs object with ignore_changes, so later applies never clobber a freshly generated site. Placeholder first, real docs on the next refresh.</p><p>You can get the generated password inspecting ouputs from terraform.</p><pre>terraform output docs_url<br>terraform output -raw docs_password</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zGfdaqfddc7sIqvkLs2dQw.png" /><figcaption>Model lineage from DBT docs</figcaption></figure><h3>Takeaways</h3><p>A few things I’ll carry into the next project:</p><ul><li><strong>Spark Connect turns a Spark cluster into an endpoint.</strong> Combined with EMR Serverless, “run dbt against the lake” stops being an infrastructure project and becomes a config file.</li><li><strong>Managed Iceberg is a genuine unlock for dbt on a lake.</strong> S3 Tables removes the compaction/cleanup toil that usually makes lakehouse dbt a part-time job.</li><li><strong>Check catalog capabilities before you commit.</strong> The REST-vs-client-catalog CTAS difference , and the drop-needs-PURGE quirk,would have been nasty surprises mid-build. Run your pipeline twice.</li><li><strong>Sharing results can be serverless too.</strong> A private S3 bucket, CloudFront, and a few lines of edge JavaScript turn dbt docs into a password-protected lineage site for pennies ,no web server, no open bucket.</li></ul><p>The full, validated example: Terraform, the dbt project, the runner, and the Airflow DAG is in the <a href="https://github.com/aws-samples/sample-dbt-spark-connect-emr-serverless-s3tables">repository</a>. Clone it, point it at a sandbox account, and you’ll have dbt transforming Iceberg tables on serverless Spark in about the time it takes MWAA to spin up.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=41d1b5a94e27" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Spark RTM mode — Low latency streaming pipelines]]></title>
            <link>https://medium.com/@neuw84/spark-rtm-mode-low-latency-streaming-pipelines-1b08d1b5ade6?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/1b08d1b5ade6</guid>
            <category><![CDATA[apache-spark]]></category>
            <category><![CDATA[apache-spark-streaming]]></category>
            <category><![CDATA[spark]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Tue, 23 Jun 2026 10:02:47 GMT</pubDate>
            <atom:updated>2026-06-23T10:11:40.662Z</atom:updated>
            <content:encoded><![CDATA[<p>For its entire life, Spark Structured Streaming has been a <em>micro-batch</em> engine. It feels continuous, but under the hood it slices your stream into small batches, plans each one, schedules tasks, runs them to completion, commits, and then starts the next one. That loop is wonderfully robust and it scales to enormous throughput, but it puts a floor under your latency: realistically somewhere around 100 ms to a few hundred ms per batch once planning and task scheduling overhead are counted. For dashboards and ETL that is invisible. For fraud scoring, live personalization, or anything that sits in front of a user waiting for an answer, that floor is the whole problem.</p><p><a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html">Continuous Processing</a> took a swing at this back in 2.3 but stayed experimental and supported only a thin set of map-like operations.</p><p><strong>Real-time Mode (RTM)</strong>, introduced in <a href="https://spark.apache.org/releases/spark-release-4.1.0.html">Apache Spark 4.1.0</a>, is the proper second attempt — and this time it keeps the same DataFrame/SQL API and the same processing guarantees as the engine you already know.</p><p>This post is a hands-on tour with a companion github repo that you can run locally: a Spark 4.1 cluster, a single-node Kafka 4.1 broker, and a notebook that reads a 10,000 events/sec stream, applies a stateless transformation, and writes it back to Kafka in Real-time Mode.</p><blockquote><em>The code for this post lives in:</em></blockquote><p><a href="https://github.com/Neuw84/rtm-spark">GitHub - Neuw84/rtm-spark: Examples on Spark RTM Structured Streaming</a></p><h2>What Real-time Mode actually is</h2><p>Real-time Mode is a new <strong>streaming trigger</strong>. Instead of waking up on an interval to process the next micro-batch, RTM launches <em>long-running tasks</em> — one per input partition — that stay up and pull records continuously.</p><p>A record flows source -&gt; transform -&gt; sink as soon as it arrives, rather than waiting for a batch boundary. Databricks, who contributed the feature, describe the goal as processing data in milliseconds without rewriting your pipeline (<a href="https://www.databricks.com/en/blog/breaking-microbatch-barrier-architecture-apache-spark-real-time-mode">architecture deep dive</a>).</p><p>The headline numbers from the Spark 4.1.0 release notes: continuous, sub-second processing, and for <strong>stateless</strong> queries latency can drop to single-digit milliseconds. That is one to two orders of magnitude below the micro-batch floor.</p><p>What you keep:</p><ul><li><strong>The same API.</strong> It is still readStream / writeStream and the same DataFrame operators. RTM is a trigger, not a new programming model.</li><li><strong>The same semantics.</strong> Checkpointing and the write-ahead log still give you fault tolerance and the exactly-once / at-least-once guarantees of the sink.</li></ul><p>What changes is <em>how</em> the query is executed underneath that API.</p><h2>A word on versions</h2><p>The release notes say Real-time Mode shipped in 4.1.0, and that is true — but getting the <strong>PySpark</strong> path to actually run took some version archaeology. Here is the lay of the land as I found it:</p><ul><li><strong>4.1.0</strong> — RTM is announced and present on the JVM, but I could not get the trigger to run end-to-end cleanly.</li><li><strong>4.1.1</strong> — works from <strong>Scala</strong>, but the Python binding for the trigger is not wired up yet.</li><li><strong>PySpark, natively</strong> — you need to move to a <strong>preview build</strong>: pyspark==4.2.0.dev5 is the first one where DataStreamWriter.trigger(realTime=...) exists in Python. If you want first-class Python support, that is the version to install.</li><li><strong>Stateful RTM</strong> — aggregations, stream-stream joins, dedup, anything that keeps state — is targeted for Spark 4.3. <strong>Today RTM is stateless-only.</strong></li></ul><p>This demo pins the cluster to <strong>Spark 4.1.2</strong> and reaches the trigger from Python through a tiny <strong>py4j bridge</strong> (more on that below), so it runs on the stable release without the preview wheel.</p><p>If you would rather call it natively from Python, install the 4.2.0.dev5 preview and use trigger(realTime=&quot;10 seconds&quot;) directly instead of the bridge.</p><h2>How the demo is wired</h2><p>The companion stack is intentionally small but complete:</p><ul><li><strong>Spark 4.1.2</strong> standalone cluster — a master and two workers (4 cores each, so <strong>8 worker cores</strong> total), plus a history server, all built on Ubuntu 24.04 / Python 3.12.</li><li><strong>Apache Kafka 4.1.0</strong>, single node, <strong>KRaft</strong> mode (no ZooKeeper). Broker and controller in one process, PLAINTEXT on kafka:9092 for the Docker network and a PLAINTEXT_HOST listener on localhost:29092 so you can reach it from your laptop.</li><li><strong>JupyterLab</strong> as the Spark driver. It runs natively on your host architecture (the cluster talks JVM-to-JVM, so the driver’s CPU arch is irrelevant), which avoids a nasty ipykernel deadlock under amd64 emulation on Apple Silicon.</li><li>A <strong>kafka-init</strong> one-shot that creates the events-input and events-output topics with 3<strong> partitions</strong> each.</li></ul><p>The Kafka Structured Streaming connector jars (spark-sql-kafka-0-10, the token provider, kafka-clients, commons-pool2) are baked straight into the Spark and Jupyter images, so there is no spark.jars.packages .</p><p>One sizing rule matters for RTM: because it runs <strong>one long-lived task per input partition</strong>, you need <strong>at least one core per partition</strong>, held for the life of the query. The input topic has 3 partitions and the cluster has 8 worker cores, so we have headroom. If you bump partitions past your core count, some readers will never get scheduled and the query stalls.</p><h2>Quick start</h2><pre>git clone https://github.com/Neuw84/rtm-spark.git<br>cd rtm-spark<br>docker compose up --build</pre><p>Then open JupyterLab at http://localhost:8888 (the token is disabled for local convenience — secure it before using this anywhere shared) and open realtime_streaming.ipynb. The notebook and the producer script are bind-mounted into the Jupyter working directory, so they are already there.</p><h2>The pipeline, cell by cell</h2><h3>1. Produce ~10,000 events/sec</h3><p>The notebook starts a background producer thread (the same logic is in kafka_producer.py if you would rather run it in a terminal). Each event is a small JSON record keyed by user_id so a user&#39;s events land on one partition:</p><pre>evt = {<br>    &quot;event_id&quot;: seq,<br>    &quot;user_id&quot;: random.randint(1, 100_000),<br>    &quot;event_type&quot;: random.choice([&quot;click&quot;, &quot;view&quot;, &quot;purchase&quot;, &quot;add_to_cart&quot;, &quot;logout&quot;, &quot;login&quot;]),<br>    &quot;country&quot;: random.choice([&quot;US&quot;, &quot;ES&quot;, &quot;DE&quot;, &quot;FR&quot;, &quot;BR&quot;, &quot;IN&quot;, &quot;JP&quot;, &quot;GB&quot;]),<br>    &quot;amount&quot;: round(random.uniform(0.0, 500.0), 2),<br>    &quot;ts&quot;: time.time(),<br>}</pre><h3>2. Read the stream and apply a stateless transform</h3><p>Every operator here is map-like — from_json, filter, withColumn — which is exactly what makes the query eligible for Real-time Mode:</p><pre>parsed = (<br>    raw.selectExpr(&quot;CAST(value AS STRING) AS json_str&quot;)<br>       .select(from_json(col(&quot;json_str&quot;), event_schema).alias(&quot;e&quot;))<br>       .select(&quot;e.*&quot;)<br>)</pre><pre>transformed = (<br>    parsed<br>      .filter((col(&quot;event_type&quot;).isin(&quot;purchase&quot;, &quot;add_to_cart&quot;)) &amp; (col(&quot;amount&quot;) &gt; 0))<br>      .withColumn(&quot;amount_with_tax&quot;, (col(&quot;amount&quot;) * lit(1.21)))<br>      .withColumn(&quot;country&quot;, upper(col(&quot;country&quot;)))<br>      .withColumn(&quot;processed_at&quot;, current_timestamp())<br>)</pre><p>We keep only revenue-bearing events, add a tax-inclusive amount, normalize the country code, and stamp a processing time. No aggregations, no joins, no state and one stage (clean fit for RTM).</p><h3>3. Enable Real-time Mode (the py4j bridge)</h3><p>This is the one piece that is version-sensitive. On the stable 4.1.2 release the PySpark trigger() wrapper exposes processingTime, once, continuous, and availableNow — but <strong>not</strong> realTime. The JVM has it, so we reach through py4j and attach Trigger.RealTime(...) to the underlying Java DataStreamWriter:</p><pre>writer = (<br>    out.writeStream<br>      .format(&quot;kafka&quot;)<br>      .option(&quot;kafka.bootstrap.servers&quot;, KAFKA_BOOTSTRAP)<br>      .option(&quot;topic&quot;, OUTPUT_TOPIC)<br>      .option(&quot;checkpointLocation&quot;, CHECKPOINT_DIR)<br>      .outputMode(&quot;update&quot;)            # required by Real-time Mode<br>)</pre><p><strong>Trigger.RealTime(&quot;&lt;duration&gt;&quot;) — the duration is a CHECKPOINT interval (min 5s), NOT a latency target. Records still flow within milliseconds.</strong></p><pre>_jvm = spark._sc._jvm<br>_real_time_trigger = _jvm.org.apache.spark.sql.streaming.Trigger.RealTime(&quot;10 seconds&quot;)<br>writer._jwrite.trigger(_real_time_trigger)</pre><pre>query = writer.start()</pre><blockquote><strong><em>If you are on the </em></strong><strong><em>4.2.0.dev5 preview</em></strong><em>, you do not need the bridge. Python gets the trigger natively, so you can write it the idiomatic way:</em></blockquote><pre>out.writeStream \ <br>  .format(&quot;kafka&quot;) \ <br>  .option(&quot;kafka.bootstrap.servers&quot;, KAFKA_BOOTSTRAP) \ <br>  .option(&quot;topic&quot;, OUTPUT_TOPIC) \ <br>  .option(&quot;checkpointLocation&quot;, CHECKPOINT_DIR) \ <br>  .outputMode(&quot;update&quot;) \ <br>  .trigger(realTime=&quot;10 seconds&quot;) \ <br>  .start()</pre><blockquote>The most common misread: that &quot;10 seconds&quot; is <strong>not</strong> a latency target. It is the <strong>checkpoint cadence</strong> — how often RTM commits offsets and progress (minimum 5 seconds). Records keep flowing through the long-running tasks within milliseconds regardless. You are not waiting 10 seconds for output.</blockquote><h2>The constraints</h2><ul><li><strong>Stateless / map-like only.</strong> No aggregations, windows, joins, or dedup yet, those are the Spark 4.3 story.</li><li><strong>Output mode must be </strong><strong>update.</strong></li><li><strong>A </strong><strong>checkpointLocation is required</strong>, same as any production streaming query.</li><li><strong>Adaptive Query Execution (AQE) is not supported</strong> .</li><li><strong>One core per input partition, held continuously.</strong> Size your cluster accordingly.</li><li><strong>The trigger duration is a checkpoint interval, not a latency knob.</strong></li></ul><h2>Reading the latency numbers</h2><p>Once the pipeline is running it is tempting to ask “so how fast is it really?” If you attach a probe that timestamps records on the way in and on the way out, you will get a distribution rather than a single number. A run on this local stack looked like:</p><pre>pipeline (produce -&gt; output write)   p50=25   p95=1299  p99=2026  max=2365  (ms)<br>end-to-end (produce -&gt; consume)      p50=31   p95=1321  p99=2040  max=2376  (ms)</pre><p>How to read it:</p><ul><li><strong>p50 (median)</strong> — half of all records were faster than this. ~25–31 ms here.</li><li><strong>p95 / p99 (the tail)</strong> — 95% / 99% of records were faster than this value. The tail is what users actually feel, and it is where streaming systems live or die.</li><li><strong>max</strong> — the single worst record in the window.</li><li><strong>pipeline</strong> measures produce -&gt; written to the output topic. <strong>end-to-end</strong> adds the consumer read on the other side, so it is always a touch higher.</li></ul><p>The median in the tens of milliseconds is the RTM story working — comfortably under the micro-batch floor. The fat tail (p99 ~2 s) is <strong>not</strong> representative of RTM on real hardware; it is the demo environment: everything (broker, master, two workers, driver) crammed onto one laptop, JSON serialization on the hot path, a Python producer, and on Apple Silicon some amd64 emulation for the workers.</p><h2>Where this leaves us</h2><p>Real-time Mode is the most interesting thing to happen to Spark streaming in years. It finally attacks the micro-batch latency floor head-on, and it does so without asking you to learn a new API or give up Spark’s fault-tolerance guarantees.</p><p><strong>The catch today is scope</strong>: stateless queries only, update mode only, and a Python story that is still settling — native PySpark support means the 4.2.0.dev5 preview build, while the stable 4.1.2 release needs the small py4j bridge shown above. Stateful Real-time Mode is the 4.3 milestone to watch.</p><p>If your pipeline is a stateless transform-and-route — enrich, filter, reshape, fan out to Kafka or another sink — you can try this <strong>today</strong> with the companion folder and watch records move in milliseconds instead of batches.</p><p>As always: benchmark with your own data, your own hardware, and your own latency requirements before drawing conclusions.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1b08d1b5ade6" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Accelerating Apache Spark with Apache DataFusion Comet]]></title>
            <link>https://medium.com/@neuw84/accelerating-apache-spark-with-apache-datafusion-comet-3290a2cec659?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/3290a2cec659</guid>
            <category><![CDATA[apache-iceberg]]></category>
            <category><![CDATA[data-lakehouse]]></category>
            <category><![CDATA[apache-spark]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Tue, 09 Jun 2026 12:59:02 GMT</pubDate>
            <atom:updated>2026-06-09T13:04:17.867Z</atom:updated>
            <content:encoded><![CDATA[<p>Apache Spark is widely adopted for large-scale data processing, but many of its performance optimizations have plateaued. Spark’s Whole-Stage Code Generation, introduced in Spark 2.0, delivered roughly 2x speed-ups over Spark 1.x, yet subsequent improvements have focused mostly on query-plan optimizations; the per-operator execution speed has largely stagnated because it is still JVM-based, row-at-a-time in many paths, and bounded by the JIT.</p><p>Meanwhile, a generation of native, vectorized SQL engines written in C++ and Rust (ClickHouse, Apache DataFusion, Databricks’ Photon, Velox, and others) execute columnar plans that comfortably outrun Spark’s JVM engine on compute-heavy workloads.</p><p>In a previous post I covered <a href="https://medium.com/@neuw84/accelerating-apache-spark-with-gluten-velox-3529c4235632">Gluten with a Velox backend</a> ( now updated to latest versions). This time we look at a different contender from the same “<em>accelerate Spark without rewriting your code</em>” family: <strong>Apache DataFusion Comet</strong>.</p><p>The code for this post is available on GitHub:</p><p><a href="https://github.com/Neuw84/spark-comet-iceberg">GitHub - Neuw84/spark-comet-iceberg: Demo on Spark acceleration with Comet</a></p><h3><strong>Why Comet?</strong></h3><p>Comet is a Spark plugin that offloads compute-intensive Spark SQL operators to a native engine built on <a href="https://datafusion.apache.org/">Apache DataFusion</a>, while keeping Spark in charge of distributed scheduling, shuffle coordination, and fault tolerance. Its design goals will feel familiar if you have used Gluten:</p><p>- <strong>Drop-in plugin</strong>: enable it through configuration (`spark.plugins`) — your DataFrame/SQL code stays the same.</p><p>- <strong>Native operators</strong>: scans, projections, filters, aggregations, joins, and sorts are executed in Rust over Apache Arrow columnar batches.</p><p>- <strong>Native shuffle</strong>: Comet ships its own accelerated shuffle (a native implementation and a JVM-based columnar fallback) so shuffle boundaries stay in columnar format.</p><p>- <strong>Graceful fallback</strong>: any operator Comet does not support is transparently handed back to vanilla Spark, so queries always run.</p><p>Where Gluten leans on Velox (C++), Comet leans on DataFusion (Rust). Both translate Spark’s physical plan into a native plan and execute it off-heap, but they are independent projects with different operator coverage, memory models, and tuning knobs.</p><h3><strong>What is Apache DataFusion?</strong></h3><p>DataFusion is an extensible, high-performance query engine written in Rust and built on Apache Arrow. It provides the reusable building blocks of a modern analytical engine, in the spirit of the <a href="https://wesmckinney.com/blog/looking-back-15-years/">composable data stack</a>:</p><p>- <strong>Arrow-native vectors</strong>: a columnar memory layout with zero-copy interchange across the ecosystem.</p><p>- <strong>Vectorized execution</strong>: a fully vectorized evaluator for scalar, aggregate, and window functions.</p><p>- <strong>Relational operators</strong>: scan, filter, projection, hash aggregate, hash/sort-merge join, sort, limit, and more.</p><p>- <strong>I/O and formats</strong>: first-class Parquet support plus pluggable object-store access (S3, GCS, Azure, local).</p><p>- <strong>Resource management</strong>: memory pools, spilling, and multi-threaded execution via a Tokio runtime.</p><p>Comet embeds DataFusion as its execution core, so Spark gets DataFusion’s native performance while retaining Spark’s distributed control plane.</p><h3><strong>Demo project: three storage scenarios</strong></h3><p>The companion repository is a self-contained, S3-backed environment (Docker Compose plus JupyterLab) very similar in shape to the Gluten/Velox demo: a Spark 4.1.0 cluster with Hadoop 3.4.1, a Spark master, two workers, a history server, and a JupyterLab UI. The difference is the accelerator (Comet 0.16.0) and, more importantly, that it benchmarks <strong>three distinct read/write scenarios</strong> so you can see where acceleration helps and where the table format dominates:</p><p>1. <strong>Parquet</strong> -&gt; <strong>Parquet</strong>: the classic datalake files path. Read Parquet from S3, run the analytics queries, and write results back to Parquet. This isolates raw compute and Parquet I/O, and is the most direct comparison to the Gluten/Velox post.</p><p>2. <strong>Parquet</strong> -&gt; <strong>Iceberg: </strong>an ingestion / transformation path. Read Parquet sources and write the query results into Apache Iceberg tables registered in the AWS Glue Data Catalog. This shows what happens when the write side goes through Iceberg’s writers and catalog commits. And usually found on bronze layer ( medallion architecture).</p><p>3. <strong>Iceberg</strong> -&gt; <strong>Iceberg:</strong> a fully table-format-native path. Read from Iceberg tables and write results back to Iceberg. This reflects a mature lakehouse where everything is already managed tables with snapshots, schema evolution, and catalog metadata or silver gold layers.</p><p>Each scenario runs the same three representative SQL workloads twice, once with Comet enabled and once with vanilla Spark , so the wall-clock comparison is apples-to-apples within a scenario.</p><h3>Benchmarks</h3><p>All three scenarios were run on a single <strong>AWS EC2 c5.4xlarge</strong> instance (16 vCPUs, 32 GB RAM)with the whole cluster: Spark master, two workers, and JupyterLab co-located in Docker on that one box. Because everything runs on a single host, shuffle never crosses the network; on a real multi-node cluster the network cost of shuffling data around would shift these numbers, so treat them as a relative compute comparison rather than an absolute production benchmark.</p><p>Each scenario runs the same three queries with Comet ON and then OFF, and we report the wall-clock time per query plus the speedup (<em>Comet OFF / Comet ON</em>, so higher is better). The three queries deliberately span a range of complexity:</p><ul><li><strong>Q_A</strong> is a light single-table multi-aggregation dominated by scan and I/O.</li><li><strong>Q_B</strong> is a medium rollup over a join</li><li><strong>Q_C</strong> is a star-schema join with a distinct count and a top-K sort which is exactly the kind of compute-bound work native engines accelerate best.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Vo3V_YZbQyRiO7jiKcBszg.png" /></figure><p>Note that for this tests we didn´t enable native Parquet writer as it is yet experimental. Moreover work is also progressing towards having native <a href="https://github.com/apache/datafusion-comet/pull/4487">Iceberg writer</a>.</p><p>Seems that the native Iceberg readers are not working as expected. I opened an <a href="https://github.com/apache/datafusion-comet/issues/4611">Issue</a>.</p><p><strong>Summary</strong></p><p>Apache DataFusion Comet brings Rust-based, Arrow-native vectorized execution to Apache Spark without changing your DataFrame or SQL code. By translating Spark’s physical plan and offloading to DataFusion off-heap, it pairs Spark’s distributed scheduling with a modern native engine — the same value proposition as Gluten/Velox, but a different engine with its own strengths and tuning with a more direct Iceberg integration provided by <a href="https://github.com/apache/iceberg-rust">iceberg-rust</a>.</p><p>The <a href="https://github.com/Neuw84/spark-comet-iceberg">companion repository</a> lets you reproduce all of this locally.</p><p>As always: <strong>benchmark with your own data</strong> and your own queries before drawing conclusions.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3290a2cec659" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Iceberg on DuckDB: End-to-End Example with Amazon S3 Tables]]></title>
            <link>https://medium.com/@neuw84/iceberg-on-duckdb-end-to-end-example-with-amazon-s3-tables-5506e8537b33?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/5506e8537b33</guid>
            <category><![CDATA[s3]]></category>
            <category><![CDATA[duckdb]]></category>
            <category><![CDATA[apache-spark]]></category>
            <category><![CDATA[apache-iceberg]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Wed, 17 Sep 2025 12:40:28 GMT</pubDate>
            <atom:updated>2025-09-17T12:40:28.584Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://iceberg.apache.org/"><strong>Apache Iceberg</strong></a> has quickly become the de-facto standard for building LakeHouses on AWS. It brings reliability and ACID guarantees to object stores like Amazon S3, enabling multiple engines to work seamlessly on the same data. From Spark and Flink to Athena and Snowflake, Iceberg tables act as a shared contract for analytics and governance.</p><p>But not every analytics problem is “big data.” In fact, most workloads don’t need a large distributed cluster.</p><p>That’s where <a href="https://duckdb.org/"><strong>DuckDB</strong></a> shines. Often called the “SQLite for analytics,” DuckDB is an embedded OLAP database that runs blazingly fast on a single machine. With vectorized execution, efficient caching, and zero server overhead, DuckDB handles surprisingly large datasets interactively. Think: data exploration, prototyping, dashboards, or any workload where the startup overhead of Spark would be overkill.</p><p>And instead of wiring all of this up manually using Amazon S3 bucket with Glue as catalog, we are going to use <a href="https://aws.amazon.com/s3/features/tables"><strong>Amazon S3 Tables</strong></a><strong>.</strong> S3 Tables deliver the first cloud object store with built-in Apache Iceberg support and streamline storing tabular data at scale with automatic maintenance. This means that tools like DuckDB can plug into Iceberg on S3 Tables with just an ARN, and instantly interoperate with other engines (Athena, EMR, Spark, Flink, etc.).</p><p>In this post, I’ll walk you through an end-to-end example of writing data into S3 Tables using <strong>DuckDB 1.4.0</strong> with nightly extensions. All you need is:</p><ul><li>An S3 Tables bucket in your AWS account.</li><li>A namespace called duckdb ont that bucket(created via the AWS console).</li><li>The ARN of your S3 Tables bucket.</li></ul><p>Let’s get started.</p><p>Open the <strong>DuckDB CLI</strong> by running:</p><pre>duckdb</pre><h3>Step 1. Install the Extensions</h3><p>DuckDB ships with a rich extension system. Since Iceberg support is still <a href="https://github.com/duckdb/duckdb-iceberg/issues/488">stabilising</a>, we’ll use nightly builds of the extensions:</p><pre>FORCE INSTALL aws FROM core_nightly;<br>FORCE INSTALL httpfs FROM core_nightly;<br>FORCE INSTALL iceberg FROM core_nightly;</pre><h3>Step 2. Load AWS and Set Up Credentials</h3><p>We’ll use the built-in AWS extension to authenticate via the standard credential chain (env vars, IAM roles, or config files):</p><pre>LOAD aws;<br>CREATE SECRET (<br>      TYPE s3,<br>      PROVIDER credential_chain<br>);</pre><h3>Step 3. Attach the Catalog</h3><p>Next, attach your S3 Tables backed Iceberg catalog. Replace &lt;&lt;CUSTOM_ARN&gt;&gt; with the ARN of your S3 table bucket:</p><pre>ATTACH &#39;&lt;&lt;CUSTOM_ARN&gt;&gt;&#39; AS s3_tables (<br>   TYPE iceberg,<br>   ENDPOINT_TYPE s3_tables<br>);</pre><h3>Step 4. Validate the Connection</h3><p>Check that your catalog is live:</p><pre>SHOW ALL TABLES;</pre><p>If your namespace duckdb is configured correctly, you’ll see it listed.</p><h3>Step 5. Create Synthetic Data</h3><p>Let’s create a table by pulling in some real CSV data from the DuckDB public datasets:</p><pre>CREATE TABLE s3_tables.duckdb.services AS<br>      FROM &#39;https://blobs.duckdb.org/nl-railway/services-2025-03.csv.gz&#39;;</pre><p>This writes an Iceberg table into our S3 Table bucket directly from DuckDB.</p><h3>Step 6. Explore with the DuckDB UI</h3><p>DuckDB also ships with a simple web UI for interactive exploration:</p><pre>CALL start_ui();</pre><p>Open the provided URL in your browser and start querying your freshly created Iceberg table.</p><p>You can create a notebook a issue this query to see the inserted data. Note that you need to change the database to <em>memory</em> on each cells as shown in below image ( use this for all the cells you run).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6SJS0mruztzNjwSqJIFFKA.png" /><figcaption>Change database for the cell</figcaption></figure><pre>from s3_tables3.duckdb.services<br>select<br> &quot;Service:RDT-ID&quot;,<br> &quot;Service:Date&quot;,<br> &quot;Service:Type&quot;,<br> &quot;Service:Company&quot;,<br> &quot;Service:Train number&quot;,<br> &quot;Service:Completely cancelled&quot;,<br> &quot;Service:Partly cancelled&quot;,<br> &quot;Service:Maximum delay&quot;,<br> &quot;Stop:RDT-ID&quot;,<br> &quot;Stop:Station code&quot;,<br> &quot;Stop:Station name&quot;,<br> &quot;Stop:Arrival time&quot;,<br> &quot;Stop:Arrival delay&quot;,<br> &quot;Stop:Arrival cancelled&quot;,<br> &quot;Stop:Departure time&quot;,<br> &quot;Stop:Departure delay&quot;,<br> &quot;Stop:Departure cancelled&quot;<br>limit 100</pre><p>This will show the data that we have previously created. But we are here to write some data too. Let’s create a table from our existing data.</p><pre>CREATE TABLE s3_tables3.duckdb.services_per_month AS<br>     SELECT<br>         month(&quot;Service:Date&quot;) AS month,<br>         &quot;Stop:Station name&quot; AS station,<br>         count(*) AS num_services<br>     FROM s3_tables3.duckdb.services<br>     GROUP BY ALL;</pre><p>Then we can visualize our newly created data, that would be available also for any compliant Iceberg engine. For example: Amazon Athena, AWS Glue, Amazon EMR, Spark, Flink,…etc.</p><pre>SELECT * FROM s3_tables3.duckdb.services_per_month;</pre><p>You can explore the column statistics on the Duckdb ui along with the capability to download query results.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xBicaciAj1gFI8c4kYx52g.png" /><figcaption>Results from our newly created table</figcaption></figure><h3>Why This Matters</h3><p>This workflow demonstrates the sweet spot of DuckDB: quick, local ( or remote if you wish) analytics with the ability to interoperate with your LakeHouse through Iceberg. You don’t need to spin up Spark or EMR to publish clean, structured datasets back to S3 or S3 Tables.</p><ul><li><strong>Speed</strong>: DuckDB is optimized for vectorized columnar execution.</li><li><strong>Simplicity</strong>: No servers, no daemons, just a single binary.</li><li><strong>Interoperability</strong>: Iceberg tables you write with DuckDB can be read and extended by Spark, Flink, Athena, and beyond.</li></ul><p>This makes DuckDB an increasingly important part of the modern analytics ecosystem: not a replacement for Spark, but a perfect companion for “small data” and interactive workloads.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5506e8537b33" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Accelerating Apache Spark with Gluten & Velox]]></title>
            <link>https://medium.com/@neuw84/accelerating-apache-spark-with-gluten-velox-3529c4235632?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/3529c4235632</guid>
            <category><![CDATA[spark]]></category>
            <category><![CDATA[gluten]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Fri, 12 Sep 2025 09:06:13 GMT</pubDate>
            <atom:updated>2025-09-12T09:49:32.455Z</atom:updated>
            <content:encoded><![CDATA[<p>Apache Spark is widely adopted for large‑scale data processing, but many of its performance optimizations have plateaued. Spark’s <strong>Whole‑Stage Code Generation</strong> introduced in Spark 2.0 delivered ~2× speed‑ups ( against Spark 1.x), yet subsequent improvements have focused mostly on query‑plan optimizations; the per‑operator execution speed has stagnated.</p><p>Meanwhile, several native SQL engines (ClickHouse, Apache DataFusion, Databricks’ Photon, Sail and <strong>Velox</strong>) implement columnar, vectorized execution in C++ and can outperform Spark’s JVM‑based engine. To make Spark scale with the speed of native engines, the open‑source <a href="http://gluten.apache.org/"><strong>Gluten</strong></a> project “glues” them together.</p><h3>Why Gluten?</h3><p><strong>Gluten</strong> is a middle layer that offloads compute‑intensive Spark SQL operators to native engines while preserving Spark’s distributed control flow. Its design focuses on the following:</p><ul><li><strong>Plan conversion:</strong> Spark’s whole‑stage physical plan is translated to a Substrait plan and passed to the native engine.</li><li><strong>Offload execution:</strong> Performance‑critical data processing is executed in native code. Gluten exposes clear JNI interfaces so Spark can call the native backend and get data back as Arrow columnar batches.</li><li><strong>Memory &amp; shuffle:</strong> Gluten manages its own off‑heap memory and implements a columnar shuffle manager; it falls back to vanilla Spark for unsupported operators.</li><li><strong>Backend flexibility:</strong> The plugin can switch native backends (currently Velox and ClickHouse) without changing Spark applications.</li></ul><p>The goal is to let users keep their DataFrame/SQL API unchanged and simply enable the plugin via configuration to accelerate Spark’s core computation.</p><h3>What is Velox?</h3><p><a href="https://github.com/facebookincubator/velox"><strong>Velox</strong></a> is an open‑source C++ execution engine that provides reusable, high‑performance building blocks for data systems. Developed by Meta and maintained with partners like IBM/Ahana, Intel, Voltron Data, Microsoft and ByteDance, Velox takes an optimized query plan (from a SQL engine or dataframe API) and executes it. It does <strong>not</strong> include its own SQL parser, dataframe API or optimizer; instead it offers a library of components that compute engines can integrate ( remember, <a href="https://wesmckinney.com/blog/looking-back-15-years/">Composable Data Stack</a>):</p><ul><li><strong>Type and vector system:</strong> An Arrow‑compatible columnar memory layout with encodings like flat, dictionary and run‑length.</li><li><strong>Vectorized expression evaluation:</strong> A fully vectorized evaluator for scalar, aggregate and window functions.</li><li><strong>Operators &amp; functions:</strong> Built‑in relational operators (scan, projection, filter, join, group by, order by, unnest, etc.).</li><li><strong>I/O and connectors:</strong> Interfaces for multiple file formats (Parquet, ORC, Nimble) and storage systems (S3, HDFS, GCS, ABFS, local files).</li><li><strong>Resource management:</strong> Memory arenas, buffer management, CPU thread pools, spilling and caching</li></ul><p>By using Velox as a backend, Gluten can offload Spark’s physical operators to a highly optimized native engine while maintaining Spark’s fault tolerance and distributed scheduling.</p><h3>Demo project: spark‑gluten‑velox</h3><p>To illustrate how Gluten with a Velox backend accelerates Spark workloads, the repository <a href="https://github.com/Neuw84/spark-gluten-velox">Neuw84/spark-gluten-velox</a> provides a Docker‑Compose deployment and a test notebook. The repo is a self‑contained environment built for S3‑backed benchmarks:</p><ul><li><strong>Components:</strong> It assembles a Spark 3.5.2 cluster with Hadoop 3.4.1, Gluten 1.4.0 using the Velox backend, JupyterLab for interactive notebooks, and S3A integration. The images are based on Ubuntu 22.04.</li><li><strong>Cluster layout:</strong> A Spark master (port 8080), two worker nodes (4 cores &amp; 4 GB RAM on-heap each), a Spark history server (port 18080) and a JupyterLab interface (port 8888)<a href="https://github.com/Neuw84/spark-gluten-velox/blob/main/README.md#L15-L20">GitHub</a>.</li><li><strong>Requirements:</strong> You need Docker and Docker Compose on x86‑64 hardware; an AWS S3 bucket for reading/writing data; and either an IAM role (recommended on EC2) or AWS credentials on your workstation configured.</li></ul><h3>Quick start</h3><ul><li><strong>Clone and build the environment</strong>: docker Compose builds images for the master, workers, history server and JupyterLab, and starts the cluster.</li></ul><pre>git clone https://github.com/Neuw84/spark-gluten-velox.git<br>cd spark-gluten-velox<br>docker-compose up - build</pre><ul><li><strong>Open JupyterLab</strong>. Navigate to http://localhost:8888 in your browser. Authentication is disabled for convenience; upload the test.ipynb notebook provided in the repo.</li><li><strong>Configure S3 access</strong>. On EC2 you can attach an IAM role that grants s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket permissions on your bucket. For local development configure your AWS credentials via aws configure or environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) and update the s3_base value in the notebook.</li><li><strong>Run the benchmark</strong>. Upload test.ipynb to the JupyterLab and execute its cells. It will first run queries with Velox enabled and then with vanilla Spark for comparison. Remember to configure the S3 bucket.</li></ul><h3>What the notebook does</h3><p>The test notebook synthesizes a <strong>50 million‑row fact table</strong> and small dimension tables to simulate a retail analytics workload. It defines constants such as number of products (N_PROD), countries, channels and days, and uses PySpark’s random functions to generate skewed data. After caching the fact and dimension tables, it defines a simple timing function that writes each query result to S3 and counts the rows back to ensure full execution. The notebook runs three representative SQL workloads</p><ul><li><strong>Query A — Heavy multi‑aggregation:</strong> Groups the fact table by country_id and channel_id and computes counts, sums, averages, standard deviation and approximate percentiles. This pattern stresses hash aggregation, projection and filter operators.</li><li><strong>Query B — Rollup (cube) aggregation:</strong> Joins the fact table with a date dimension and uses rollup(date_key, country_id, product_id) to compute revenue, quantity and average discount across multiple grouping levels.</li><li><strong>Query C — Star schema join and top‑K sort:</strong> Joins the fact table with broadcast dimensions (countries and channels) and the date dimension, computes gross and net revenue, and orders by gross descending, taking the top 5,000 rows. Broadcast joins and top‑K sorts test Velox’s vectorized join and sort operators.</li></ul><p>After executing the queries, the notebook prints wall‑clock times and resets spark.gluten.enabled to false to run the same workload with the vanilla Spark engine<a href="https://github.com/Neuw84/spark-gluten-velox/blob/main/test.ipynb#L249-L259">GitHub</a>.</p><h3>Configuration highlights</h3><p>To enable Gluten/Velox acceleration, the notebook constructs a SparkSession with several critical settings:</p><ul><li>Enable the Gluten plugin: spark.plugins = org.apache.gluten.GlutenPlugin.</li><li>Use the columnar shuffle manager: spark.shuffle.manager = org.apache.spark.shuffle.sort.ColumnarShuffleManager.</li><li>Turn on off‑heap memory and allocate 8 GB: spark.memory.offHeap.enabled = true and spark.memory.offHeap.size = 8g.</li><li>Configure the Velox bundle JAR on the driver and executors: the Docker image already includes gluten-velox-bundle-spark3.5_2.12-linux_amd64-1.4.0.jar and adds it to the classpath in the notebook.</li></ul><p>For S3 integration, the compose file and notebook use the Hadoop 3.4.1 S3A connector with “magic” committers, improving write performance. You can view progress and metrics in the Spark UIs (master at :8080, history server at :18080, and driver UI at :4040).</p><h3>Customizing and scaling</h3><p>The demo is designed to be flexible:</p><ul><li><strong>Scaling workers:</strong> Adjust SPARK_WORKER_CORES and SPARK_WORKER_MEMORY in the docker-compose.yml to give executors more cores and memory.</li><li><strong>Adding workers:</strong> Duplicate the worker service and assign different ports.</li><li><strong>Changing data size:</strong> Modify N_ROWS, REPART or other parameters in the notebook to simulate different workload sizes.</li><li><strong>Custom queries:</strong> Feel free to add your own workloads or business logic to the notebook; the notebook’s timing function is generic.</li><li><strong>Enhancing the compose setup:</strong> The current compose file keeps event logs local; you can improve it to store logs on S3 so the Spark history server works across runs and workers.</li></ul><h3>Benchmarks</h3><p>Let’s do a concrete benchmark. We will run each set 3 times and do the average with different test sizes. We will run them on a c5.4xlarge instance. Remember as we are running all containers on a single instance we are not touching real network. On a real production scenario this could impact the results ( shuffling data around the network is not cheap). Test parameters:</p><p><strong>Default settings</strong>:</p><pre>N_ROWS = 50_000_000 <br>N_PROD = 50_000<br>N_CTRY = 32<br>N_CH = 6<br>N_DAYS = 90<br>REPART = 256</pre><p><strong>Medium settings</strong>:</p><pre>N_ROWS = 80_000_000   <br>N_PROD = 70_000<br>N_CTRY = 32<br>N_CH   = 6<br>N_DAYS = 120<br>REPART = 256         </pre><p><strong>High settings</strong>:</p><pre>N_ROWS = 110_000_000<br>N_PROD = 80_000<br>N_CTRY = 32<br>N_CH   = 6<br>N_DAYS = 120<br>REPART = 256</pre><p>Then, let’s see the results for each query:</p><p><strong>Query A:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/562/1*9u8MAs4uvGBIN2FBTfSqJw.png" /></figure><p><strong>Query B</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/562/1*dV-03-MssinYnwKazpm4Ow.png" /></figure><p><strong>Query C</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/562/1*CrZpN1FR2QPpEjjVbEfHzQ.png" /></figure><p>Velox reduces total wall‑time by roughly 23 % compared to Vanilla Spark. Expressed as an average of the relative improvements for each query‑size combination, the overall performance gain is about <strong>25 % faster</strong> for Velox versus Vanilla Spark on this specific test.</p><h3>Summary</h3><p>Gluten with a Velox backend brings modern vectorized execution to Apache Spark without requiring users to change their DataFrame/SQL code. By translating Spark’s physical plan to a Substrait plan and offloading to Velox, it combines Spark’s robust distributed scheduling with Velox’s native performance and memory efficiency. The spark-gluten-velox repository demonstrates how to set up a local Spark cluster with Gluten/Velox, configure S3 integration, and benchmark performance across realistic analytics workloads. Once you run the notebook and measure the wall‑clock times, you’ll have concrete numbers to quantify how much faster your queries can run on Spark when backed by Velox.</p><p><strong>Remember to test for your specific workload!</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3529c4235632" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Debezium and Kafka Connect with Iceberg ( part II)]]></title>
            <link>https://medium.com/@neuw84/using-debezium-and-kafka-connect-with-iceberg-part-ii-0c5ecea68c5e?source=rss-7442e895eb8b------2</link>
            <guid isPermaLink="false">https://medium.com/p/0c5ecea68c5e</guid>
            <category><![CDATA[debezium]]></category>
            <category><![CDATA[amazon-s3-tables]]></category>
            <category><![CDATA[apache-iceberg]]></category>
            <category><![CDATA[kafka]]></category>
            <dc:creator><![CDATA[Angel Conde]]></dc:creator>
            <pubDate>Wed, 26 Mar 2025 19:34:15 GMT</pubDate>
            <atom:updated>2025-03-26T19:53:25.068Z</atom:updated>
            <content:encoded><![CDATA[<p>On <a href="https://medium.com/@neuw84/using-debezium-and-kafka-connect-with-iceberg-part-i-aca001ee166b">part I</a> we have seen how we can deploy an end to end solution for CDC using Open Source libraries that can be deployed easily using <em>docker-compose. </em>This will allow us to iterate quickly, make tests and develop.</p><p>Now it is time to move this into a productive setup on top of native AWS stack. Remember that you can go the previous blogpost or the <a href="https://github.com/Neuw84/debezium-kafka-connect-docker-s3tables">Github repo </a>for a more detailed view of the components. For that we will use the AWS services:</p><ul><li><strong>AWS Glue</strong>: here we will put our <em>data-producer</em> Python script. This is in charge of inserting data to the database.</li><li><strong>Amazon Aurora PostgreSQL</strong>: we are going to Aurora with Postgre compability to deploy our database.</li><li><strong>Amazon Managed Service for Kafka (MSK)</strong>: we will use MSK to have a managed Kafka broker.</li><li><strong>MSK Connect: </strong>we will use this feature of MSK to register both Debezium Source connector and Iceberg Connect Sink.</li><li><strong>S3 Tables</strong>: we will use the new launched Apache Iceberg Rest EndPoint (IRC) for S3 Tables as our catalog and will store Iceberg data on top of S3 Tables.</li></ul><p>A 100 hundred feet overview of the architecture can be seen on the following picture:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C3AF3gjY2dJaRwgkiTyyZg.png" /><figcaption>Overview of the AWS native deployment architecture</figcaption></figure><p><strong>Requirements</strong>:</p><ul><li>An Amazon VPC well configured with minimum of 2 private subnets. You can use this <a href="https://docs.aws.amazon.com/vpc/latest/userguide/create-vpc.html#create-vpc-and-other-resources">guide</a>.</li><li>Deploy a MSK cluster with version 3.6 with 2 nodes ( 1 for each private subnets) with <strong>Unauthenticated access</strong> <em>Enabled.</em> Although we are on a private subnet on a production setup you should disable this. You need to enable topic auto creation via <a href="https://console.aws.amazon.com/msk/home?#/configurations">Cluster Configurations</a>. The corresponding property is:</li></ul><pre>auto.create.topics.enable=true</pre><ul><li>An Amazon Aurora PostgreSQL 17 database. Any size will work, the instance should be deployed into one of the private subnets. Moreover, Debezium needs logical replication to be enabled. You can follow this <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.Replication.Logical.Configure.html">guide</a>.</li><li>A <a href="https://docs.aws.amazon.com/glue/latest/dg/add-job-python.html">Python Shell Glue job</a> that will run inside the VPC and with network access (Security Groups) to the database.</li><li>You will need to create a <a href="https://console.aws.amazon.com/s3/table-buckets">S3 Table bucket</a> where our CDC data will live with Analytics Services integration enabled.</li><li>You need to deploy the MSK Connectors. We are going to deep dive on this next.</li></ul><p>You can download the connectors used for the blogpost on the <a href="https://github.com/Neuw84/debezium-kafka-connect-docker-s3tables/releases/tag/1.0">releases</a> page on the Github repository or you can go to the official project to get the latest ones 😀 . If you want to have detailed instructions on how can you build and use a custom plugin and create a MSK Connect conector you can read <a href="https://aws.amazon.com/blogs/aws/introducing-amazon-msk-connect-stream-data-to-and-from-your-apache-kafka-clusters-using-managed-connectors/">blog</a>.</p><p>Let´s use first the CloudFormation template to automate this deployment. Go to <em>us-east-1 </em>region and open the <a href="http://console.aws.amazon.com/cloudformation/">CF console</a>, upload the CF template available on the <a href="https://github.com/Neuw84/debezium-kafka-connect-docker-s3tables/blob/main/infrastructure.yml">repo</a> and wait for deployment, you can chooose some parameters like the Aurora DB <em>username</em> and <em>password</em>. Then, is a matter of:</p><ol><li>Create a S3 Table bucket while enabling analytics integration. Take note of the <em>arn</em> of the table<em>.</em></li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*d3Pz_Gd5dJ5MOCio57CVXw.png" /><figcaption>S3 Table creation config</figcaption></figure><p>Go to the S3 bucket created by the CF template ( by default should be <em>debezium-lakehouse-account-id</em>…..) and create two folders <em>athena</em> for storing Athena query results and <em>connectors</em> that will be used for storing MSK Connect custom plugins.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XNHvJ4ORCEX-GDM5hSa0BA.png" /><figcaption>S3 folders required</figcaption></figure><p>2. Create the data producer. You should go to Glue console and create a Python shell script job. Choose upload script and upload<em> data-producer.py </em>file from the repo.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bzelTD9ciVDAqsAOH0ycIg.png" /><figcaption>Glue script</figcaption></figure><p>Then, you need to change the DB parameters as needed on the script. For getting the Aurora <em>endpoint</em> and <em>port</em> you can go to the <a href="https://console.aws.amazon.com/rds/">RDS console</a> ( take note of the <em>writer</em> instance).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9QCo5SHPNy3jRJuQ-f-qWQ.png" /><figcaption>Aurora Console web UI</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EpeGfP4XBcSaI5Uxm2qDxg.png" /><figcaption>Part of the script that needs to be changed</figcaption></figure><p>Note that we could also use env. parameters or Glue job parameters ( or <strong>AWS Secrets Manager</strong> secrets), however for a demo more than enough. Remember that as <strong>best practice</strong> is better not to store any <em>Username</em> and <em>Password</em> on code.</p><p>Next step is clicking on job details and add on <em>Job parameters</em>:</p><pre>--additional-python-modules : psycopg2-binary</pre><p>Also, you need to select the Connection created by the CF template that will allow our job to reach the Aurora DB. You can use this screenshot as a guide.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VD0Jugwj6awsohcV9X12vQ.png" /><figcaption>Glue Job parameters and Connections</figcaption></figure><p>In basic properties you will need to put a name (like data-producer) and select the role name. This will have the name of CF stack followed by <em>GlueRole</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Yy017GBi9qVZW3UenELVIw.png" /><figcaption>Basic properties</figcaption></figure><p>Finally, you can click on <em>Save</em> and then <em>Run</em> the job.</p><p>3. You will upload the downloaded connectors to S3 in the <em>connector</em> folder you have previously created and use them to create a custom plugin.</p><p>4. Create one for Debezium with these properties where you can see that the only change is the <em>endpoint</em>, <em>user</em> and <em>password. </em>Use <em>Kafka Connect version 3.7</em> and the default config for workers, choose the role with the CF stack name for it ( should be <em>stackName-MSKConnectRole-xxxx)</em>:</p><pre>connector.class=io.debezium.connector.postgresql.PostgresConnector<br>database.user=REPLACE_WITH_USERNAME<br>database.dbname=inventory<br>tasks.max=1<br>database.server.name=dbserver1<br>schema.include.list=public<br>database.port=3306<br>plugin.name=pgoutput<br>topic.prefix=ice<br>database.hostname=REPLACE_WITH_AURORA_ENDPOINT<br>database.password=REPLACE_WITH_PASSWORD<br>table.include.list=public.customers<br>value.converter=org.apache.kafka.connect.json.JsonConverter<br>key.converter=org.apache.kafka.connect.json.JsonConverter</pre><p>3. For the Iceberg Sink Connector you can use this as a guide, like on the previous step, use <em>Kafka Connect version 3.7</em> and let the other defaults, choose the role with the CF stack name for it ( should be <em>stackName-MSKConnectRole-xxxx)</em>:</p><pre>connector.class=org.apache.iceberg.connect.IcebergSinkConnector<br>s3.region=us-east-1<br>iceberg.tables.evolve-schema-enabled=true<br>tasks.max=1<br>iceberg.catalog.client.region=us-east-1<br>iceberg.tables.upsert-mode-enabled=false<br>iceberg.tables.dynamic-enabled=false<br>errors.deadletterqueue.context.headers.enable=false<br>iceberg.tables.auto-create-enabled=true<br>iceberg.tables=my_database.customers_table<br>iceberg.catalog.rest.sigv4-enabled=true<br>value.converter=org.apache.kafka.connect.json.JsonConverter<br>errors.log.enable=true<br>key.converter=org.apache.kafka.connect.json.JsonConverter<br>topics=ice.public.customers<br>iceberg.catalog.io-impl=org.apache.iceberg.aws.s3.S3FileIO<br>iceberg.control.commit.interval-ms=60000<br>iceberg.catalog.uri=https://s3tables.us-east-1.amazonaws.com/iceberg<br>iceberg.catalog.warehouse=arn:aws:YOURS3TABLE_ARN<br>iceberg.catalog.rest.signing-name=s3tables<br>iceberg.control.topic=iceberg-connector-control-v1<br>iceberg.catalog.type=rest<br>iceberg.catalog.rest.signing-region=us-east-1</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2vFFxDoVQg29liU_uGYyTQ.png" /><figcaption>You should end with two connectors ( one “source” using Debezium and one “sink” using Iceberg)</figcaption></figure><p>4. With the connectors ready is a matter to wait a little bit and go to <a href="https://console.aws.amazon.com/athena">Athena console</a> and set the S3 folder that we have previously created <em>athena</em> for storing query results.</p><p>Now we can go to Athena query editor and see how our data looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y5LM4qL_gM4tILTaIxa6gw.png" /></figure><p>Remember that you can deploy the AWS CloudFormation template to automate the deployment, although more things can be automated that is up to the reader 😉.</p><p>Happy Iceberg-ing!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0c5ecea68c5e" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>