<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Shadow Byte]]></title><description><![CDATA[Step-by-step engineering logs of a custom C++ LSM-tree storage engine. Deep dives into DuckDB internals, memory sanitizers, and database architecture.]]></description><link>https://theshadowbyte.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 07:52:31 GMT</lastBuildDate><atom:link href="https://theshadowbyte.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building an LSM-Tree MemTable in C++: Testing LookupKey's Buffer and Failure Paths ]]></title><description><![CDATA[How I designed an invariant and fault-test harness to verify the 200-byte inline-buffer/heap-allocation boundary, exercise failure behavior, and prevent…]]></description><link>https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-testing-lookupkey-s-buffer-and-failure-paths</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-testing-lookupkey-s-buffer-and-failure-paths</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Tue, 20 Oct 2026 15:20:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/5378c5f6-b1ff-4cce-a154-6a7984e91d0f.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I completed <strong>Stage 3: Invariant &amp; Fault Harness Creation</strong> of MemTable. Here are 4 things I learnt in the process:</p>
<ol>
<li><p>As I began with answering the LLM’s questions, “How will the harness programmatically assert that memory resides on the stack (<code>space_</code>) for 200 bytes and on the heap (<code>new char[]</code>) for &gt; 200 bytes without breaking encapsulation” was one of them. I didn’t know how to answer the question.</p>
<p>Here’s what I learnt:</p>
<p>In the case where the memory resides in stack, the <code>start_</code> pointer, being a part of the LookupKey object, will reside within memory occupied by that object. A dynamic allocation on the other hand, will not.</p>
<p>Hence, to verify whether memory resides in the inline buffer (<code>space_</code>) or heap without exposing private members, the solution is to compare <code>key.mem()</code>(the function that returns <code>start_</code>) against the memory address range of the <code>LookupKey</code> instance itself(<code>&amp;key</code> marks the beginning of the <code>LookupKey</code> object, while <code>&amp;key + 1</code> marks its end):</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/a1104408-292a-496c-8eb0-b7976f188d0f.png" alt="" />

<ol>
<li><p>As I progressed, I was encountered with the most fundamental question: why are test cases even written? What does passing all test cases imply? On the other hand, what does failing a few imply about our code?</p>
<p>Test harnesses are a reflection of the constraints we already define and the outcomes we guarantee in Stage 1. Test cases are written to</p>
<ol>
<li><p>Immobiliize the architectural contract i.e. ensure that the code handles every case it claims it can.</p>
</li>
<li><p>Verify whether the code guarantees deterministic failure routing rather than undefined behaviour by Intentionally forcing catastrophic environmental states.</p>
</li>
</ol>
<p>Verify every part of code except for, obviously, initializations. Conditions are hotspots to check.</p>
</li>
<li><p>What does a volatile benchmark accumulator mean?</p>
<p>In benchmark routines, compilers actively optimize away code if they detect that a value is computed or read but never used.</p>
<p>If you write a loop to sweep across memory purely to trigger AddressSanitizer (ASan) bounds checks:</p>
<pre><code class="language-cpp">for (size_t i = 0; i &lt; key.size(); ++i) {
    char c = key.mem()[i]; 
</code></pre>
<p>An optimizing compiler (<code>-O2</code> or <code>-O3</code>) sees that <code>c</code> is never read afterwards. It removes the loop entirely as dead code. Because the memory access loop gets eliminated, ASan never actually scans the bytes at runtime, making the sanitizer test useless.</p>
<p>A <strong>volatile benchmark accumulator</strong> prevents this dead-code elimination:</p>
<pre><code class="language-cpp">volatile char sink = 0;
for (size_t i = 0; i &lt; key.size(); ++i) {
    sink = key.mem()[i]; 
}
</code></pre>
<p>The <code>volatile</code> keyword tells the C++ compiler that writing to <code>sink</code> has visible side effects outside the program's observable state. The compiler is legally forced to emit instructions that execute every single memory read and write, ensuring ASan intercepts every byte access during the test sweep.</p>
</li>
<li><p>A very small learning but one worth mentioning: when writing code, we often change the names of our variables where we realise it’s required, but fail to implement that name change throughout the code. Hence it is important to replace obsolete nomenclature with what is the latest.</p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR3 — Investigating ART Checkpoint Overhead Behind Insert Slowdowns]]></title><description><![CDATA[Reproducing a 244% increase in batch latency and tracing how persistent Adaptive Radix Tree (ART) indexes affect insert performance as a DuckDB table…]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr3-investigating-art-checkpoint-overhead-behind-insert-slowdowns</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr3-investigating-art-checkpoint-overhead-behind-insert-slowdowns</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Tue, 20 Oct 2026 02:47:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/256c3e9f-e14d-4aa2-b8ac-420680cd81b5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After discovering that my previous DuckDB PR candidate had overlapping work already underway, I had to choose another open issue. I chose <a href="https://github.com/duckdb/duckdb/issues/24848">issue #24848</a>: a performance regression where inserts into a uniquely indexed table become increasingly expensive as the table grows. This post documents my investigation into the role of Adaptive Radix Tree (ART) checkpointing in that slowdown.</p>
<p>Before I describe the bug, you should know the meaning of the following keywords: <a href="https://theshadowbyte.hashnode.dev/duckdb-pr2-from-one-issue-to-the-next-pr-candidate">index, ART and checkpoint</a>.</p>
<p>Note: Checkpointing has a slightly more specific meaning in the current context; when you execute <code>INSERT</code>, rows go into memory and a Write-Ahead Log (WAL). Once the WAL hits a size threshold (default: <code>16 MiB</code>), DuckDB triggers an automatic checkpoint. The checkpoint flushes memory buffers to disk and resets the log so disk space doesn't grow infinitely.</p>
<h3>Core Issue</h3>
<p>When inserting data into DuckDB, checkpointing occurs. The bug in #24848 is that DuckDB re-writes the entire ART index from scratch on every automatic checkpoint, rather than only writing the newly inserted or modified keys (delta).</p>
<p>As a table grows from 100,000 rows to 3,000,000 rows, saving the index changes from a tiny task into an O(N) operation. Every few seconds, the engine freezes to re-serialize millions of unchanged index keys.</p>
<p>The issue reporter proved this behavior by running a synthetic benchmark:</p>
<table>
<thead>
<tr>
<th><strong>Execution Configuration</strong></th>
<th><strong>First 5 batches(Avg)</strong></th>
<th><strong>Last 5 batches(Avg)</strong></th>
<th><strong>Latency Degradation</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Unique index, plain <code>INSERT</code>, default <code>checkpoint_threshold</code></td>
<td>0.385 s</td>
<td>1.326 s</td>
<td><strong>+244%</strong></td>
</tr>
<tr>
<td>Unique index, plain <code>INSERT</code>, <code>checkpoint_threshold = '4GB'</code></td>
<td>0.306 s</td>
<td>0.447 s</td>
<td><strong>+46%</strong></td>
</tr>
<tr>
<td>No index, plain <code>INSERT</code></td>
<td>0.083 s</td>
<td>0.084 s</td>
<td>+1%</td>
</tr>
</tbody></table>
<ul>
<li><p>Without a unique index, insert performance is flat.</p>
</li>
<li><p>Disabling auto-checkpoints removes the periodic latency spikes entirely.</p>
</li>
<li><p>Re-enabling default auto-checkpoints causes per-batch latency to spike linearly with total table row count.</p>
</li>
</ul>
<h3>Root Cause</h3>
<p>In <a href="https://github.com/duckdb/duckdb/blob/main/src/execution/index/art/art.cpp"><code>src/execution/index/art/art.cpp</code></a> and storage checkpoint handlers, calling <code>ART::Serialize</code> traverses the entire radix tree from root to leaves regardless of whether nodes were modified during the current transaction window.</p>
<p>A potential optimization could be introducing <strong>dirty-subtree tracking</strong>:</p>
<ol>
<li><p>Flag ART nodes as "dirty" when modified during writes.</p>
</li>
<li><p>During <code>StorageManager::CreateCheckpoint</code>, bypass clean subtrees and append only dirty nodes/blocks to storage.</p>
</li>
<li><p>Clear dirty flags post-checkpoint.</p>
</li>
</ol>
<p>I'm currently working on a reproducible benchmark and investigating DuckDB's ART checkpointing path. I'll publish a follow-up covering the reproduction, root-cause analysis, and a patch if the investigation supports a viable fix.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — Phase 3: Regression Testing and Validating the Upstream Fix]]></title><description><![CDATA[Building a SQLLogicTest reproducer, investigating test failures, and validating DuckDB's upstream VARCHAR allocation fix against a focused regression test…]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-3-regression-testing-and-validating-the-upstream-fix</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-3-regression-testing-and-validating-the-upstream-fix</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Fri, 16 Oct 2026 07:01:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/f9ee3822-a340-4407-970c-4e2f883d118a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>1. Inspecting checkpoint finalization and update-state ownership</h2>
<p>Despite we had proven that the reintegration of <code>ClearUpdates()</code> stopped the OOM, my first decision was to inspect three source files: <code>column_data_checkpointer.cpp</code>, <code>column_data.cpp</code>, and <code>standard_column_data.cpp</code>. Why? Because the passing of the 35 assertions of the test established that the intervention prevents OOM from occuring. It did not although, establish that clearing update state was safe across every checkpoint path, transaction state, and concurrent access pattern. There’s several more reasons which will be mentioned later in the blog.</p>
<p>The central function was <code>ColumnDataCheckpointer::FinalizeCheckpoint()</code>:</p>
<pre><code class="language-cpp">void ColumnDataCheckpointer::FinalizeCheckpoint() {
    for (idx_t i = 0; i &lt; checkpoint_states.size(); i++) {
        auto &amp;state = checkpoint_states[i].get();

        if (!has_changes[i]) {
            state.SetUnchanged();
            WritePersistentSegments(state);
        }

        state.original_column.ClearUpdates();
    }
}
</code></pre>
<p>Now,</p>
<ol>
<li><p>Unnecessary strings were being copied into the <code>StringHeap</code>, then retained longer than necessary because the update state was not being cleared until checkpoint finalization. But since changes in a column can also be ineffective, the update state—as opposed to the original state stored in the <code>data</code> member—need not persist in every case.</p>
</li>
<li><p><code>ColumnData::Checkpoint()</code> and <code>StandardColumnData::Checkpoint()</code> both invoke <code>Checkpoint()</code> followed by <code>FinalizeCheckpoint()</code>. However, both contain early returns that can bypass finalization. We therefore needed to trace both paths to verify which states reach the cleanup loop.</p>
</li>
<li><p><code>ClearUpdates()</code> and <code>UpdateInternal()</code> acquire <code>update_lock</code>, but <code>HasChanges(start_row, end_row)</code> accesses <code>updates</code> without that mutex. We thus needed to verify whether the mutex inside <code>ClearUpdates()</code> is sufficient to establish safety or not.</p>
</li>
</ol>
<p>I ran the following commands to investigate the above questions and insights:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/9e991b0a-a5fa-48ee-b6d4-43f48337deed.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/77e9a0f8-c52a-4f6d-919d-a61ac86dff17.png" alt="" />

<h2>2. Designing a regression test</h2>
<p>I ran three searches:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/45390380-4c52-4764-9c69-e1a16704f606.png" alt="rg searches text in files. The -l flag prints filenames rather than matching lines, -i ignores capitalization, and head limits the number of displayed results." />

<p>The intention behind this was to examine DuckDB's existing test conventions; DuckDB uses its SQLLogicTest framework, which has its own syntax and conventions, such as how DuckDB expresses repeated updates, checkpoint operations, expected results, and persistent-database behavior.</p>
<p>Each search targeted a different aspect of our problem:</p>
<table>
<thead>
<tr>
<th>Search</th>
<th>Why we needed it</th>
</tr>
</thead>
<tbody><tr>
<td><code>ON CONFLICT ... DO UPDATE</code></td>
<td>To find existing tests using the same kind of upsert operation as our reproducer.</td>
</tr>
<tr>
<td><code>duckdb_memory()</code>, <code>memory_limit</code>, <code>checkpoint_threshold</code></td>
<td>To find tests that inspect memory usage or configure memory and checkpoint behavior.</td>
</tr>
<tr>
<td><code>checkpoint</code> and <code>DELETE FROM</code></td>
<td>To find tests involving checkpointing and deletion, the other important parts of our workload.</td>
</tr>
</tbody></table>
<p>The searches led me to four particularly useful files:</p>
<ul>
<li><p><a href="https://github.com/duckdb/duckdb/blob/main/test/sql/storage/update/nop_updates.test_slow"><code>test/sql/storage/update/nop_updates.test_slow</code></a></p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/main/test/sql/storage/checkpoint_with_pending_updates.test_slow"><code>test/sql/storage/checkpoint_with_pending_updates.test_slow</code></a></p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/main/test/sql/storage/test_repeated_checkpoint.test_slow"><code>test/sql/storage/test_repeated_checkpoint.test_slow</code></a></p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/main/test/sql/upsert/upsert_basic.test"><code>test/sql/upsert/upsert_basic.test</code></a></p>
</li>
</ul>
<p>Based on the knowledge acquired from above operations, a new test was created at:</p>
<p><code>test/sql/storage/update/</code><a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/upsert_delete_checkpoint_memory.test_slow"><code>upsert_delete_checkpoint_memory.test_slow</code></a></p>
<p>It used a file-backed database with a 384 MB memory limit and a 1 MB checkpoint threshold. The test created a table with a primary key, a VARCHAR payload, and a deletion flag. It inserted 300,000 initial rows containing strings of approximately 385–387 bytes.</p>
<p>Each of ten rounds then performed two upserts against 100,000 keys, followed by deleting rows whose deletion flag was true. The final expected row count was 285,000.</p>
<p>The intent was to exercise the same combination of long strings, no-op updates, deletes, and checkpointing that characterized the original failure.</p>
<h2>3. The first test run failed with an internal error</h2>
<p>The first execution of the custom SQLLogicTest did not produce the expected out-of-memory error. It failed during the first upsert with:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/7620a8c8-7746-407a-97c0-680b41f71304.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/6faf504b-b8f9-4bfb-a197-7500447e156a.png" alt="" />

<p>The stack trace passed through <code>RowGroupCollection::Update()</code> and the physical update pipeline.</p>
<p>To investigate, the source was searched for <code>GetUpdateStatistics()</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/028157b5-6516-4288-806b-d6d54ea38792.png" alt="" />

<p><code>RowGroup::Update()</code> contained a dereference of the returned pointer:</p>
<pre><code class="language-cpp">MergeStatistics(column.index, *col_data.GetUpdateStatistics());
</code></pre>
<p><code>StandardColumnData::GetUpdateStatistics()</code> could return null when neither the column's update statistics nor its validity column's update statistics existed.</p>
<p>This made the failure path worth examining, particularly given the earlier experiment in which immediate update-state cleanup had caused a similar error. However, the exact cause of this first SQLLogicTest failure was not conclusively established.</p>
<h2>4. Disabling cleanup reproduced the memory regression</h2>
<p>To determine whether <code>ClearUpdates()</code> affected the test outcome, the cleanup call was temporarily disabled in <code>column_data_checkpointer.cpp</code>.</p>
<p>A backup of the patched file was retained:</p>
<pre><code class="language-shell">cp src/storage/table/column_data_checkpointer.cpp \
  /tmp/column_data_checkpointer.cpp.with_clear_updates
</code></pre>
<p>After disabling the call, the test was rebuilt and executed again. This time, it failed with the expected memory exhaustion:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/2b41ae65-d896-4be2-b6b3-e257453fe9b7.png" alt="" />

<p>As can be seen, the failure occurred during the upsert at line 30 of the custom test.</p>
<p>The original source was then restored:</p>
<pre><code class="language-shell">cp /tmp/column_data_checkpointer.cpp.with_clear_updates \
  src/storage/table/column_data_checkpointer.cpp
</code></pre>
<p>After rebuilding <code>unittest</code>, the test passed:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/b845846d-64b7-441c-9ecd-01610cbc1d0d.png" alt="" />

<p>The sequence was significant. Disabling cleanup reproduced OOM, while restoring cleanup and rebuilding yielded a passing test. This was strong experimental evidence that update-state cleanup affected the memory behavior.</p>
<p>It did not establish that the cleanup intervention was the best upstream fix, nor did it explain the exact cause of the earlier internal error.</p>
<h2>5. Comparing the known-good and first-bad revisions</h2>
<p>The investigation also prepared separate worktrees for the two revisions defining the regression boundary:</p>
<ul>
<li><p>Known-good parent: <code>763e942790c6cbc627ac401d38ce87dadec20514</code></p>
</li>
<li><p>First-bad candidate: <code>8d61c6fb35de65a2cc98c8405ba3caaa5348deb7</code></p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/26ee5bff-084e-47d9-92c2-07ea266c78c2.png" alt="" />

<p>The custom regression test was copied into both worktrees so that the test could be run without changing the original working tree:</p>
<pre><code class="language-shell">cp test/sql/storage/update/upsert_delete_checkpoint_memory.test_slow \
  /tmp/duckdb-good-check/test/sql/storage/update/

cp test/sql/storage/update/upsert_delete_checkpoint_memory.test_slow \
  /tmp/duckdb-bad-check/test/sql/storage/update/
</code></pre>
<p>The recorded output from the unpatched first-bad revision showed the expected OOM failure. The patched test passed in the original working tree.</p>
<p>The supplied terminal record does not conclusively establish the final test outcome on the known-good parent. That comparison must therefore remain unconfirmed rather than being presented as a completed three-way validation.</p>
<p>This distinction mattered because a convincing regression test should distinguish the known-good implementation, the failing implementation, and the implementation with the proposed fix.</p>
<h2>6. Inspecting the upstream fix changed the direction of the investigation</h2>
<p>The next important development came from getting to know that an upstream resolution associated with <a href="https://github.com/duckdb/duckdb/issues/25162">issue #25162</a> already existed in <a href="https://github.com/duckdb/duckdb/pull/26291">PR #26291</a>.</p>
<p>The relevant source change was in <a href="https://github.com/duckdb/duckdb/blob/main/src/storage/table/update_segment.cpp"><code>src/storage/table/update_segment.cpp</code></a>, in <code>UpdateSegment::Update()</code>.</p>
<p>Before the fix, the VARCHAR statistics path copied strings into the update segment's string heap before the effective-update filtering had completed. Thus, strings could be copied even when the operation ultimately had no effective updates.</p>
<p>The upstream change moved that copying until after the effective-update filter. If the update count was zero, the function returned before performing the unnecessary string allocations.</p>
<p>This addressed the mechanism at its source:</p>
<ul>
<li><p>The experimental <code>ClearUpdates()</code> intervention released update state during checkpoint finalization.</p>
</li>
<li><p>The upstream fix prevented unnecessary string copies from being allocated in the first place.</p>
</li>
</ul>
<p>The distinction was important for both engineering correctness and this blog. The cleanup intervention provided useful evidence about the update-state lifecycle, but the upstream implementation solved the problem at an earlier point in the operation.</p>
<h2>7. Building the upstream revision in a persistent worktree</h2>
<p>To validate the accepted implementation independently, the PR was fetched into a separate Git reference and checked out in a dedicated worktree.</p>
<p>The initial worktree lived under <code>/tmp/duckdb-pr26291</code>. The build was interrupted, and after returning to WSL the temporary directory was no longer available. Subsequent commands accidentally ran in the original <code>~/duckdb</code> checkout, still at the first-bad revision. The focused upstream test then reported: <code>No test cases matched</code>.</p>
<p>That result meant the test had not run; it was not a failure of the upstream implementation.</p>
<p>The problem was resolved by recreating the worktree under the home directory:</p>
<p><code>~/duckdb-pr26291</code></p>
<p>The checkout was verified at commit:</p>
<p><code>b66f6070de3f115dd34cd3e21340223041f4728e</code></p>
<p>The build was then completed in that persistent worktree.</p>
<h2>8. The focused upstream regression test passed</h2>
<p>The upstream test was run with:</p>
<pre><code class="language-powershell">build/release/test/unittest \
  "test/sql/storage/update/noop_string_update_memory.test"
</code></pre>
<p>The result was:</p>
<pre><code class="language-powershell">All tests passed (4 assertions in 1 test case)
</code></pre>
<p>The focused test created a table containing a 65,536-byte string, checkpointed it, performed a no-op update, and checked the allocator measurement against a small threshold.</p>
<p>This test targeted the unnecessary allocation more directly than the ten-round workload. Passing it established that the upstream implementation satisfied its own regression assertion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/9e158c8d-2753-4b5a-93e1-5d4ed99b37ef.png" alt="" />

<h2>9. The original file-backed reproducer passed on the upstream build</h2>
<p>The original <code>repro.sql</code> was copied into the upstream worktree. The database and WAL were removed before running it, ensuring that the experiment began with a fresh file-backed database:</p>
<pre><code class="language-powershell">cp ~/duckdb/repro.sql ./repro.sql

rm -f test.db test.db.wal output.txt

./build/release/duckdb test.db &lt; repro.sql &gt; output.txt 2&gt;&amp;1
rc=$?

echo "Exit status: $rc"
</code></pre>
<p>The recorded exit status was zero, and the output contained:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/2c9188eb-24f6-40c5-aacb-7c7efa680ded.png" alt="" />

<p>All ten rounds completed, and 285,000 rows remained after each round.</p>
<p>This was the first direct confirmation that the upstream-fixed build could execute the original workload from start to finish without the previous OOM failure.</p>
<p>Neighbouring tests were also run:</p>
<ul>
<li><p><a href="https://github.com/duckdb/duckdb/blob/e08224d653fcade680e471709e9a9035223989df/test/sql/storage/update/nop_updates.test_slow#L4"><code>nop_updates.test_slow</code></a>: passed, 9 assertions.</p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/e08224d653fcade680e471709e9a9035223989df/test/sql/upsert/upsert_basic.test#L4"><code>upsert_basic.test</code></a>: passed, 154 assertions.</p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/e08224d653fcade680e471709e9a9035223989df/test/sql/storage/update/nop_update_tpch.test_slow#L4"><code>nop_update_tpch.test_slow</code></a>: skipped because TPC-H was required.</p>
</li>
</ul>
<p>The skipped test was not counted as a pass.</p>
<h2>10. Replacing the integer MiB measurement with raw bytes</h2>
<p>The original reproducer reported allocator usage by dividing the byte count by 1,048,576 and casting the result to an integer. The output showed <code>allocator_mib = 0</code> in all ten rounds.</p>
<p>That measurement was ambiguous: integer conversion can hide nonzero usage below one MiB.</p>
<p>Rather than modifying the original script, a separate copy was created: <a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/repro-pr26291-raw.sql"><code>repro-pr26291-raw.sql</code></a></p>
<p>A Python <a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/revised_repro.py">script</a> replaced the ten measurement queries. It required exactly ten matches before writing the file, preserving the rest of the workload. The revised query reported:</p>
<ul>
<li><p><code>allocator_rows</code>: the number of rows returned for the <code>ALLOCATOR</code> tag.</p>
</li>
<li><p><code>allocator_bytes</code>: the reported raw byte total.</p>
</li>
<li><p><code>rows</code>: the table's row count.</p>
</li>
</ul>
<p>The instrumented reproducer was run again against a fresh file-backed database on the upstream build.</p>
<p>The reported measurements were:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/6744b2cb-66f7-4932-8766-9b90458c7aa4.png" alt="" />

<p>The workload again exited successfully and printed <code>completed all rounds without failing</code>.</p>
<p>The <code>ALLOCATOR</code> tag was present at every sample. Its reported usage was zero in eight rounds and 16 KiB in two rounds. The important observation was that the readings did not increase steadily across rounds, unlike the historical pattern that rose from approximately 80 MiB to more than 220 MiB before OOM.</p>
<h2>11. Preserving the experiment and restoring the original working tree</h2>
<p>After verifying the upstream implementation, the <code>ClearUpdates()</code> experiment was preserved separately:</p>
<pre><code class="language-powershell">cd ~/duckdb

git diff --binary &gt; /tmp/clear-updates-experiment.patch

cp test/sql/storage/update/upsert_delete_checkpoint_memory.test_slow \
  /tmp/upsert_delete_checkpoint_memory.test_slow
</code></pre>
<p>The three modified source files were restored, and the custom regression test was removed from the original repository. The subsequent status output showed only untracked investigation artifacts, such as reproducer scripts, logs, the Parquet file, and supporting shell scripts.</p>
<p>These files were left in place rather than being removed indiscriminately, preserving the evidence needed for the investigation and technical write-up.</p>
<h2>Conclusion</h2>
<p>The investigation progressed from auditing checkpoint finalization and update-state ownership to creating a reproducer, testing a cleanup intervention, examining the accepted upstream change, and validating that change against both a focused regression test and the original workload.</p>
<p>The evidence supports the following conclusions:</p>
<ol>
<li><p>The original workload reproducibly accumulated allocator memory and eventually exhausted the configured memory limit on the first-bad implementation.</p>
</li>
<li><p>Disabling <code>ClearUpdates()</code> reproduced OOM in the custom SQLLogicTest, while restoring cleanup and rebuilding yielded a passing test.</p>
</li>
<li><p>The upstream fix moved string copying after ineffective updates had been filtered out, preventing the unnecessary allocations at their source.</p>
</li>
<li><p>The upstream focused regression test passed, and the original file-backed reproducer completed all ten rounds.</p>
</li>
<li><p>The raw-byte measurements showed no steadily increasing allocator usage in the upstream run.</p>
</li>
<li><p>The temporary cleanup intervention was preserved for reference but removed from the working tree; the final account distinguishes it from the implementation merged upstream.</p>
</li>
</ol>
<p>The final successful experiment was not merely a passing query. It combined a passing focused regression test, successful completion of the original ten-round workload, stable logical row counts, and raw-byte measurements that did not exhibit the original accumulating pattern.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — Debugging GDB's Internal Crash During Symbol Loading]]></title><description><![CDATA[Diagnosing a GDB failure while inspecting DuckDB's C++ destructor, then isolating startup configuration with DEBUGINFOD_URLS, -nx, -iex, and --args.]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-debugging-gdb-s-internal-crash-during-symbol-loading</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-debugging-gdb-s-internal-crash-during-symbol-loading</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Tue, 13 Oct 2026 15:50:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/23835f91-5eff-4593-8938-a3e400d3d96e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>While making the documenting the last blog, there was a piece of hands-on evidence I was trying to recreate for a section of it(particularly 4. <strong>I</strong>nvestigating whether the original column survived checkpointing).</p>
<p>I started by putting in the following commands serially into the gdb:</p>
<pre><code class="language-powershell">(gdb) set disassembly-flavor intel
(gdb) disassemble /r 'duckdb::ColumnData::~ColumnData()'
</code></pre>
<p>It returned: <code>❌️ No symbol table is loaded. Use the "file" command.</code></p>
<p>I first turned off the debugging-info download, and then loaded the DuckDB executable into GDB:</p>
<pre><code class="language-powershell">(gdb) set debuginfod enabled off
(gdb) file ./build/release/duckdb
</code></pre>
<p>Following was the error that occured:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/f0258b9b-41cf-47a3-98cd-1b11953f3b36.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/1e66a564-017f-469d-be17-b4e31121f416.png" alt="" />

<p>As can be seen, GDB suffered an internal failure and aborted. To solve this, I ran the following command:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/f41dc923-ac7b-4786-9736-e7eff5972cc1.png" alt="" />

<p>Here’s each keyword explained as well as the effect it has:</p>
<ul>
<li><code>DEBUGINFOD_URLS</code> : Is an environment variable. An environment variable is a named setting that a program can read when it runs. Since nothing is directly adjacent to the ‘=’ symbol, the value of that variable is an empty string.</li>
</ul>
<p>This prevents GDB from receiving any debuginfod server URLs through this variable. Debuginfod is a service that can provide debugging information, such as debug symbols, over the internet.</p>
<ul>
<li><p><code>-q</code>: Only suppresses GDB's introductory message and some startup information.</p>
</li>
<li><p><code>-nx</code>: Normally, GDB may read configuration commands from files such as <code>.gdbinit</code>.</p>
</li>
</ul>
<p>This option tells GDB not to read those initialization files and hence reduces the chance that existing GDB configurations interfere with your debugging session.</p>
<ul>
<li><p><code>-iex 'set debuginfod enabled off'</code>: The <code>-iex</code> tells GDB to execute a GDB command(<code>'set debuginfod enabled off'</code>) early in its startup process. <code>'set debuginfod enabled off'</code> effectively instructs GDB not to use debuginfod to retrieve debugging information.</p>
</li>
<li><p><code>--args</code>: informs GDB of the following executable and its command-line arguments.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — Phase 2: Tracing a Memory Leak to Checkpoint Cleanup]]></title><description><![CDATA[Investigating retained StringHeap allocations through C++ source analysis, GDB object-lifetime tracing, allocation instrumentation, and a targeted fix…]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-2-tracing-a-memory-leak-to-checkpoint-cleanup</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-2-tracing-a-memory-leak-to-checkpoint-cleanup</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Tue, 13 Oct 2026 13:25:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/fec34aff-f8b1-45ff-8021-cf60859e8188.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>1. Inspecting the three checkpoint-related files</h2>
<p>The investigation began with a source-level comparison between the parent of the first-bad commit, <code>763e942790c6cbc627ac401d38ce87dadec20514</code>, and <code>8d61c6fb35de65a2cc98c8405ba3caaa5348deb7</code>.</p>
<p>I initially inspected three files:</p>
<ul>
<li><p><a href="https://github.com/duckdb/duckdb/blob/v2.0-cyanoptera/src/storage/table/column_checkpoint_state.cpp"><code>src/storage/table/column_checkpoint_state.cpp</code></a></p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/v2.0-cyanoptera/src/storage/table/column_data_checkpointer.cpp"><code>src/storage/table/column_data_checkpointer.cpp</code></a></p>
</li>
<li><p><a href="https://github.com/duckdb/duckdb/blob/v2.0-cyanoptera/src/storage/table/standard_column_data.cpp"><code>src/storage/table/standard_column_data.cpp</code></a> using the command:</p>
</li>
</ul>
<pre><code class="language-shell">git diff 8d61c6fb35de65a2cc98c8405ba3caaa5348deb7^ 8d61c6fb35de65a2cc98c8405ba3caaa5348deb7 -- src/storage/table/column_checkpoint_state.cpp
</code></pre>
<p>for <code>column_checkpoint_state.cpp</code> and so on.</p>
<p>They were inspected first because they felt like a sensible starting point as checkpointing was the step just prior to the deallocation step which was removed in the first bad commit. The following were the changes in each of the files:</p>
<h3>What changed in <code>ColumnCheckpointState</code>?</h3>
<p>The old <code>column_data</code> member was replaced by a reference named <code>original_column</code>, together with an optional non-owning <code>parent_state</code> pointer and an owning <code>shared_ptr&lt;ColumnData&gt;</code> named <code>result_column</code>.</p>
<p>This reflected a substantial architectural change. Rather than always checkpointing by moving segments out of the original column and rebuilding its segment tree, the newer design could construct or obtain a result column separately.</p>
<p>Methods that previously wrote to <code>new_tree</code> were changed to append through <code>GetResultColumn().GetSegmentTree()</code>. The checkpoint code also began distinguishing the original column from the column being produced by the checkpoint.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/91d6237e-3a0b-422e-9aca-1c8d8e2b46e8.png" alt="" />

<p>Note: <code>original_column</code> and <code>result_column</code> are not necessarily two distinct objects. In the unchanged-column case, <code>SetUnchanged()</code> assigns <code>original_column.shared_from_this()</code> to <code>result_column</code>. The result pointer can therefore refer to the original object. The diagram showing separate columns should be understood as illustrating the case where a new result column is created, not every checkpoint.</p>
<h3>What changed in <code>ColumnDataCheckpointer</code>?</h3>
<p>The checkpointing code increasingly used <code>GetResultColumn()</code> for operations on the resulting column, while retaining access to <code>original_column</code> for the original data and metadata.</p>
<p>The most significant difference appeared in <code>FinalizeCheckpoint()</code>.</p>
<p>In the parent implementation, finalization handled the existing segments, reset the compression function, moved the new segments back into the column's tree, and called <code>ClearUpdates()</code>.</p>
<p>In the first-bad implementation, that finalization logic had largely disappeared. The remaining logic handled the unchanged-column case by calling <code>SetUnchanged()</code> and <code>WritePersistentSegments()</code>.</p>
<p>This was the first strong lead: the checkpoint design had changed, and cleanup that previously occurred at finalization had been removed.</p>
<h3>What changed in <code>StandardColumnData</code>?</h3>
<p>The validity data changed from an embedded object to a shared-pointer-based representation, and the validity checkpoint state gained a <code>parent_state</code> relationship.</p>
<p>This made the new checkpoint-state hierarchy clearer, but the pointer changes alone were not enough to explain the memory regression. The more consequential question was how the result column was installed into the live row group and what happened to the original column's update state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/e217acc5-d131-4a73-9c74-70b6a193b375.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/cd1ae682-b8e3-46bd-9780-2f9db6d88835.png" alt="" />

<p>They were not, however, the whole story. Further inspection of <code>RowGroup::WriteToDisk()</code> and <code>ColumnData</code> was needed to understand how the checkpoint state related to the live column and who owned the relevant memory.</p>
<h2>2. Reproducing the regression under controlled conditions</h2>
<p>The next step was to establish that the regression was real and to identify a repeatable boundary.</p>
<p>The parent revision, <code>763e942</code>, passed the established reproduction. The first-bad commit, <code>8d61c6f</code>, failed under the constrained-memory workload.</p>
<p>The workload used a 384 MiB memory limit, a 1 MiB checkpoint threshold, and a table containing 300,000 rows. Each payload was generated using a long VARCHAR string:</p>
<p><code>repeat('x',384) || (i%997)::VARCHAR</code></p>
<p>Each round performed two 100,000-row upserts using <code>ON CONFLICT ... DO UPDATE</code>, followed by a deletion of rows marked for deletion. The workload then recorded allocator memory and row counts.</p>
<p>The failing file-backed runs showed this pattern:</p>
<table>
<thead>
<tr>
<th>Round</th>
<th>Allocator memory</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>80 MiB</td>
<td>Continues</td>
</tr>
<tr>
<td>2</td>
<td>146 MiB</td>
<td>Continues</td>
</tr>
<tr>
<td>3</td>
<td>Approximately 226–228 MiB</td>
<td>Out of memory</td>
</tr>
</tbody></table>
<p>The failing allocation varied between runs: some reports showed a 16 MiB allocation failure, while another recorded a 256 KiB block failure. The exact allocation size was not perfectly repeatable, but the broader regression was.</p>
<p>For a more granular view of memory growth during individual operations, see <a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/repro_auto_trace.sql"><code>repro_auto_trace.sql</code></a>. This diagnostic variant records allocator usage after each upsert, deletion, and explicit checkpoint, helping distinguish memory growth during DML from changes observed after checkpointing.</p>
<h3>Correcting the database-mode mistake</h3>
<p>An important correction was distinguishing in-memory from file-backed execution.</p>
<p>When I used <code>./build/release/duckdb test.db &lt;</code> <a href="https://github.com/duckdb/duckdb/issues/25162"><code>repro.sql</code></a>, all the rounds were passed; OOM did not occur:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/f3f4cf35-517f-497b-8966-c626f3c7bf4b.png" alt="" />

<p>This is so because the above command does not use a database file. The canonical file-backed command was: <code>./build/release/duckdb test.db &lt; repro.sql &gt; output.txt 2&gt;&amp;1</code>, which when I used, OOM occured in 10/10 runs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/003524d5-2688-4718-aec9-743f516b4f8b.png" alt="" />

<p>The fact that changing only the execution harness changes whether the OOM occurs means <strong>the</strong> workload is timing/execution-sensitive. That was evidence of execution sensitivity, but not proof of the cause. Later redirected runs passed after the cleanup fix, so redirection itself cannot be treated as the established explanation.</p>
<h2>3. Building the ownership model</h2>
<p>Source inspection established the main ownership relationships.</p>
<p>A <code>RowGroup</code> holds shared pointers to its columns. <code>ColumnData</code> owns its segment tree and its <code>updates</code> pointer, which is a <code>unique_ptr&lt;UpdateSegment&gt;</code>. The update segment in turn owns its update-related state, including a <code>StringHeap</code>.</p>
<p>The checkpoint state has a different role:</p>
<ul>
<li><p><code>original_column</code> is a non-owning <code>ColumnData&amp;</code>.</p>
</li>
<li><p><code>result_column</code> is a <code>shared_ptr&lt;ColumnData&gt;</code>.</p>
</li>
<li><p><code>parent_state</code> is a non-owning link to another checkpoint state.</p>
</li>
<li><p>The checkpoint state acts as temporary bookkeeping during checkpointing.</p>
</li>
</ul>
<p>The change in <code>RowGroup::WriteToDisk()</code> was particularly relevant. The first-bad implementation added an <code>AssignSharedPointer()</code> call that replaced a row group's column pointer with the result column's pointer.</p>
<p>This led to a useful mental model: there is a live column, a temporary checkpoint state, and potentially a separately constructed result column. The checkpoint state itself does not automatically own the original column simply because it stores a reference to it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/cf5775b3-6015-4ea6-8a9c-d6be49f28d7d.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/8c3ef22d-e9b9-47db-81ca-7a308258a89f.png" alt="" />

<p>(The first row group diagram represents the <code>RowGroup</code> dependencies in the good design).</p>
<p>At this stage, the architecture suggested that the new checkpoint path might be leaving state behind. It did not yet establish which retained state accounted for the memory increase.</p>
<h2>4. Investigating whether the original column survived checkpointing</h2>
<p>The next experiments focused on the lifetime of a specific <code>UpdateSegment</code>.</p>
<p>An early attempt to inspect arbitrary <code>UpdateInternal()</code> and <code>WriteToDisk()</code> calls produced different <code>this</code> addresses. Those observations could not be connected to one another. An update call on one <code>ColumnData</code> object was not evidence about the lifetime of another column seen in an earlier checkpoint.</p>
<p>To avoid that mistake, I restarted the experiment and tracked one exact <code>UpdateSegment</code> address from construction. I then installed a conditional breakpoint on its destructor.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/474d61dc-efce-4993-af0d-dc23038cdbf7.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/deeb0d08-52e7-433e-8c44-087336e0d0ae.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/ff7ed76b-5e75-4926-84b8-a463aae7a9dd.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/acebcb86-7150-4612-b155-f9e97609680e.png" alt="" style="display:block;margin:0 auto" />

<p>During the bad run:</p>
<ol>
<li><p>The tracked object was constructed through <code>ColumnData::UpdateInternal()</code>.</p>
</li>
<li><p>Checkpoint finalization occurred while the object was still alive.</p>
</li>
<li><p>The workload progressed through the allocator-growth rounds and eventually reported OOM.</p>
</li>
<li><p>The tracked object's destructor breakpoint fired afterward, through destruction of its owning <code>ColumnData</code> and subsequent row-group cleanup.</p>
</li>
</ol>
<p>A separate assembly inspection showed the destructor loading the <code>UpdateSegment</code> pointer from an offset within <code>ColumnData</code> and calling the update segment's destructor. In the observed case, that pointer matched the tracked address.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/6860f143-e8ca-4a0c-849d-55075c8cc321.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/49d1b0a7-71a6-437d-ba11-926cc6482d53.png" alt="" />

<p>This was much stronger than merely noticing a constructor and a destructor breakpoint somewhere in the run. It connected one specific object's lifetime to the failure.</p>
<p>I thus concluded that the tracked <code>UpdateSegment</code> survived checkpoint activity and was destroyed only after the OOM had been reported in that run.</p>
<p>It did not, by itself, establish which member of the surviving object accounted for the accumulated memory.</p>
<h2>5. Testing the first cleanup hypothesis</h2>
<p>The source diff had revealed that <code>ClearUpdates()</code> had been removed from finalization. It was natural to test whether restoring it fixed the workload.</p>
<p><code>ClearUpdates()</code> resets the owning <code>updates</code> pointer. As a result, destroying the <code>UpdateSegment</code> also destroys its members and their owned state.</p>
<p>To isolate checkpoint effects from the DML workload, I also used <a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/repro_checkpoint_isolation.sql"><code>repro_checkpoint_isolation.sql</code></a>. It suppresses ordinary automatic checkpointing with a 10 GB threshold and explicitly measures allocator memory before and after checkpoints. This was a diagnostic experiment with a different checkpoint configuration, not a replacement for the canonical regression reproducer.</p>
<p>There was also a separate experiment that reset <code>updates</code> immediately after <code>updates-&gt;Update()</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/53c4362a-9b8d-421e-991e-7ffd4bd7bade.png" alt="" />

<p>That approach caused an internal error because subsequent processing expected the <code>updates</code> object to remain available.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/0d49aad7-b64b-4544-8d3e-398272cb5456.png" alt="" />

<p>The early reset was therefore removed. The cleanup needed to be investigated at checkpoint finalization, not forced into the update method immediately after an update call.</p>
<h2>6. Investigating the wrong allocation path: <code>UpdateInfo</code></h2>
<p>The next question was whether the retained memory came from allocated <code>UpdateInfo</code> entries.</p>
<p>The workload definitely executed <code>UpdateSegment::Update()</code>. GDB backtraces established the path from the update operator through <code>DataTable::Update()</code>, <code>RowGroup::Update()</code>, and <code>ColumnData::UpdateInternal()</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/86dad431-1a1e-482a-b7aa-6a78c42423f8.png" alt="" />

<p>I initially instrumented <code>UpdateInfo</code> allocation using a <code>live_update_info_bytes</code> counter. That counter remained zero. The corresponding allocation instruction was not reached.</p>
<p>The reason became clear from the execution order in <code>UpdateSegment::Update()</code>:</p>
<ol>
<li><p>Run the statistics-update callback.</p>
</li>
<li><p>Return early if that callback produces a zero count.</p>
</li>
<li><p>Otherwise, determine the effective updates.</p>
</li>
<li><p>Return early again if there are no effective updates.</p>
</li>
<li><p>Only then initialize the update information.</p>
</li>
</ol>
<p>GDB showed the effective-update function returning zero in the observed path, so <code>InitializeUpdateInfo()</code> was bypassed.</p>
<p>Consequently, the zero <code>UpdateInfo</code> counter was not evidence that the update operation did no work. It was evidence that I had been instrumenting the wrong allocation mechanism.</p>
<p>I thus went ahead to examine what happens before the effective-update check.</p>
<h2>7. Discovering the VARCHAR string-heap path</h2>
<p>The source revealed the decisive ordering.</p>
<p>For VARCHAR, <code>GetStatisticsUpdateFunction()</code> selects <code>UpdateStringStatistics()</code>. In this function, non-inline strings are copied through:</p>
<p><code>segment-&gt;GetStringHeap().AddBlob(str)</code></p>
<p>That happens during the statistics phase, before <code>UpdateSegment::Update()</code> determines that an update is ineffective and returns.</p>
<p>The payload strings in the reproduction were around 385–387 bytes long, so they were non-inline strings. The callback therefore had a path to copy substantial string payload even when the later effective-update check returned zero.</p>
<p>The next measurement hence targeted this specific path, rather than <code>UpdateInfo</code>.</p>
<h3>Instrumentation difficulties and fixes</h3>
<p>The string-heap counter took several iterations to instrument correctly:</p>
<ul>
<li><p>String code was initially inserted into <code>TemplatedUpdateNumericStatistics()</code>, causing compile failures when the compiler instantiated it for numeric types. The numeric template was restored.</p>
</li>
<li><p>Some code was outside the relevant loop, so <code>str</code>, <code>idx</code>, and <code>not_null_count</code> were out of scope. The instrumentation was moved inside the existing <code>UpdateStringStatistics()</code> branches.</p>
</li>
<li><p>The per-segment <code>string_heap_bytes</code> counter was initially declared at namespace scope. It was moved into the <code>UpdateSegment</code> class so each object had its own count.</p>
</li>
<li><p>GDB function calls were interrupted by other threads hitting checkpoint breakpoints. The checkpoint breakpoint was disabled around the counter read; scheduler locking was left off so the multithreaded workload could continue normally.</p>
</li>
<li><p>A function address calculated before a rebuild became stale. Calling it returned an impossible counter value, which was discarded. The symbol offset was checked with <code>nm -C</code> and the runtime address recalculated using the current executable mapping.</p>
</li>
</ul>
<p>These were instrumentation errors, not evidence about the original bug.</p>
<h3>The positive measurements</h3>
<p>Once the counter and call path were working, GDB stopped directly in <code>UpdateStringStatistics()</code>. The backtrace showed the function being called through <code>UpdateSegment::Update()</code> on the real update path.</p>
<p>After an invocation, the live string-payload counter read 792,292 bytes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/ab9eb2b5-2aa1-4b6d-8626-912540130bc5.png" alt="" />

<p>Later in the same failing workload, it rose to:</p>
<ul>
<li><p>114,132,226 bytes, approximately 108.8 MiB;</p>
</li>
<li><p>189,575,562 bytes, approximately 180.8 MiB;</p>
</li>
<li><p>187,641,118 bytes, approximately 178.9 MiB near the OOM.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/20fbcf05-b615-4f36-a3b8-0535b9ae8685.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/5c248918-ec1e-4752-bec5-8826268ffce1.png" alt="" />

<p>The allocator had reached 226 MiB by round 3, followed by the allocation failure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/1de3a050-9761-4430-97ee-d86b4d2be27e.png" alt="" />

<p>These were not perfectly aligned per-round samples, and the counter measured payload bytes rather than the allocator's full memory usage. Nevertheless, the counter's growth was close in scale to the workload's approximately 77 MB of input string payload per round and strongly supported the retained-string-memory mechanism.</p>
<h2>8. The decisive checkpoint-cleanup control experiment</h2>
<p>With the string-heap mechanism identified, I returned to the cleanup hypothesis and applied a controlled intervention to the first-bad commit.</p>
<p>The intervention restored the <code>ClearUpdates()</code> declaration and implementation and called:</p>
<p><code>state.original_column.ClearUpdates();</code></p>
<p>at the end of each <code>ColumnDataCheckpointer::FinalizeCheckpoint()</code> state-processing iteration.</p>
<p>I did not restore the old segment-movement implementation, the compression reset, or the old <code>new_tree</code> movement logic. The intervention was intentionally narrow.</p>
<p>The cleanup method was restored as:</p>
<p><code>updates.reset()</code> under the existing update lock.</p>
<p>The call was placed after the conditional that handles unchanged columns, so cleanup ran for each checkpoint state. The earlier experimental reset inside <code>ColumnData::UpdateInternal()</code> was removed.</p>
<h3>Instrumented result</h3>
<p>The file-backed workload completed all ten rounds. Allocator memory was:</p>
<table>
<thead>
<tr>
<th>Round</th>
<th>Allocator memory</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>80 MiB</td>
</tr>
<tr>
<td>2</td>
<td>50 MiB</td>
</tr>
<tr>
<td>3</td>
<td>50 MiB</td>
</tr>
<tr>
<td>4</td>
<td>50 MiB</td>
</tr>
<tr>
<td>5</td>
<td>50 MiB</td>
</tr>
<tr>
<td>6</td>
<td>50 MiB</td>
</tr>
<tr>
<td>7</td>
<td>50 MiB</td>
</tr>
<tr>
<td>8</td>
<td>50 MiB</td>
</tr>
<tr>
<td>9</td>
<td>50 MiB</td>
</tr>
<tr>
<td>10</td>
<td>50 MiB</td>
</tr>
</tbody></table>
<p>The live string-payload counter was zero at the checkpoint sample points in that cleanup-enabled run. A zero before and after a particular checkpoint is not by itself proof that the checkpoint released memory within that invocation. The useful combined result is that the workload completed and allocator usage remained bounded instead of following the earlier accumulation trajectory.</p>
<p>This was the strongest causal experiment: the same reproduction failed with the original first-bad checkpoint cleanup and completed when the cleanup was restored at finalization.</p>
<h2>9. Removing instrumentation and validating the final patch</h2>
<p>Once the cleanup-control result was obtained, I removed the temporary measurement code from <code>update_segment.cpp</code> and <code>update_segment.hpp</code>. The final diff then contained only three source files and seven insertions:</p>
<ol>
<li><p>Add the <code>ClearUpdates()</code> declaration in <code>column_data.hpp</code>.</p>
</li>
<li><p>Implement <code>ColumnData::ClearUpdates()</code> in <code>column_data.cpp</code>.</p>
</li>
<li><p>Call <code>state.original_column.ClearUpdates()</code> in <code>ColumnDataCheckpointer::FinalizeCheckpoint()</code>.</p>
</li>
</ol>
<p><code>git diff --check</code> produced no output. A separate diff check confirmed that the temporary instrumentation files no longer had changes. <code>ColumnData::UpdateInternal()</code> was also inspected to ensure that the invalid early-reset experiment was gone.</p>
<p>After a successful rebuild without instrumentation, I ran the exact file-backed reproduction ten times. Each run used a fresh database and WAL, and the harness required both a zero exit status and the final completion message while rejecting any OOM message.</p>
<p><strong>All ten runs passed:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/d36b574c-87fa-4d71-b6d1-d3b0c3d2fc63.png" alt="" />

<p>This is the final successful experiment to report. It is stronger than a passing run with temporary instrumentation because the patch was tested after that instrumentation had been removed.</p>
<h2>Conclusion</h2>
<p>The investigation has identified a specific cleanup regression and a targeted change that prevents memory accumulation in the reproducer. The fix is promising, but the investigation is not complete: a permanent regression test and broader validation against DuckDB's test suite remain outstanding.</p>
<p>In the next article, I'll document the patch, the reasoning behind the change, the design of the regression test, and the results of testing it against the relevant DuckDB code paths. I'll also document any failures encountered and the further changes needed before considering the contribution ready for an upstream pull request.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — How a Missing ClearUpdates() Retained StringHeap Allocations]]></title><description><![CDATA[Tracing how ineffective VARCHAR updates allocate string data inside UpdateSegment::StringHeap before an early return, and how checkpoint cleanup determines…]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-how-a-missing-clearupdates-retained-stringheap-allocations</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-how-a-missing-clearupdates-retained-stringheap-allocations</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Sun, 11 Oct 2026 10:41:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/2ac52d65-74de-424a-a565-696bbc74f03d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After a week of investigating DuckDB's memory leak, I traced the accumulation to a specific change in checkpoint finalization: the removal of a call to <code>ClearUpdates()</code>.</p>
<p>The investigation revealed how an update could copy string data into an <code>UpdateSegment</code>'s <code>StringHeap</code> before DuckDB determined that the proposed update had no effect. Because the early return occurred after those allocations, and the <code>UpdateSegment</code> was no longer explicitly cleared during checkpoint finalization, the copied string data could remain allocated across checkpoint cycles.</p>
<p>Tracing this sequence narrowed the investigation to the code responsible for the memory accumulation and provided a concrete direction for understanding the regression. The following traces the sequence of events that caused the memory accumulation, introducing each relevant concept alongside the code path where it becomes necessary:</p>
<p><code>ColumnData</code> is the <a href="https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-3"><code>BlockHandle</code></a> of DuckDB columns in a given table i.e. it’s the in-memory representation of a column.</p>
<p>It has 2 members which are of relevance: <code>data</code> and <code>updates</code>. <code>data</code> is the existing column data, organized into segments. <code>updates</code> is a smart pointer(<code>unique_ptr</code>) to an <code>UpdateSegment</code>.</p>
<p>Now what is <code>UpdateSegment</code>? When DuckDB processes the updates which are to be made to a column, it can create an <code>UpdateSegment</code> object to manage that work.</p>
<ul>
<li>For this investigation, the important fact is that <code>UpdateSegment</code> contains a <code>StringHeap</code>.</li>
</ul>
<p>What is a <code>StringHeap</code>? Strings are processed in the form of batches. DuckDB represents individual string values using an object called <code>string_t</code>. Short strings are stored directly inside the <code>string_t</code> object (inline), whereas longer strings are represented using metadata, including their length, and a pointer to character bytes stored elsewhere. <code>StringHeap</code> is a separate object that stores copies of these character bytes(given that the copy fulfills certain conditions), allowing them to remain valid independently of the original storage.</p>
<ul>
<li><p>Because <code>ColumnData</code> owns <code>updates</code> through a <code>unique_pt</code>, destroying or resetting that pointer destroys <code>UpdateSegment</code>, consequently destroying its string heap.</p>
</li>
<li><p>When an upsert arrives, DuckDB</p>
<ol>
<li><p>Enters the update path i.e. it executes <code>UpdateSegment::Update()</code>,</p>
<pre><code class="language-plaintext">ColumnData::UpdateInternal()
    ↓
UpdateSegment::Update()
</code></pre>
</li>
</ol>
<p>and then,</p>
</li>
</ul>
<ol>
<li>Calls its statistics-update callback.</li>
</ol>
<p>What is a callback? A callback is a function, a lambda, a function object, or any other callable mechanism in C++ passed as a parameter into another function so that the receiving function can invoke it at a point of its choosing.</p>
<p>For VARCHAR values, <code>UpdateStringStatistics()</code> copies non-inlined strings into <code>StringHeap</code> through <code>AddBlob()</code>.</p>
<pre><code class="language-cpp">StringStats::Update(stats.statistics, str);

if (!str.IsInlined()) {
    update_data[idx] = segment-&gt;GetStringHeap().AddBlob(str);
}
</code></pre>
<p>Conceptually, <code>AddBlob()</code> copies the string bytes into storage managed by the heap and returns a string representation referring to the copied data.</p>
<ul>
<li><p>After the statistics callback finishes, DuckDB checks whether the proposed values actually differ from the existing values. The code calls:</p>
<pre><code class="language-cpp">count = get_effective_updates(
    update_format, ids, count, sel, base_data, vector_offset
);

if (count == 0) {
    return;
}
</code></pre>
<p>When the proposed values are the same as the existing values, the count = 0. This implies no effective update. Consequently, the function returns before <code>InitializeUpdateInfo()</code> is reached i.e. before creating <code>UpdateInfo</code> entries. This is relevant as the initialisation of <code>UpdateInfo</code> objects could have been a possible cause of the memory accumulation and consequently, the OOM.</p>
<p>Here is the full sequence:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/37f6b010-264b-4980-a035-d48939afe5e0.png" alt="" />

<p>The key is that step 3 has already allocated and copied the string <em>before</em> step 5 rejects the update. Returning early does not undo those copies.</p>
</li>
<li><p>Before the first bad commit, checkpoint finalization called <code>ClearUpdates()</code>, whose implementation was:</p>
<pre><code class="language-cpp">void ColumnData::ClearUpdates() {
    lock_guard&lt;mutex&gt; update_guard(update_lock);
    updates.reset();
}
</code></pre>
<p>Here, <code>updates</code> is a <code>unique_ptr&lt;UpdateSegment&gt;</code>. Resetting it destroyes the owned <code>UpdateSegment</code>, including its <code>StringHeap</code>. That released the heap's allocations.</p>
</li>
<li><p>In the first bad commit, the previous <code>ClearUpdates()</code> call disappeared from checkpoint finalization. Consequently, the <code>UpdateSegment</code> and its heap could survive checkpointing instead of being reclaimed there. As subsequent updates processed more strings, additional character bytes could accumulate in that surviving heap.</p>
</li>
</ul>
<p>I’ll soon publish the investigation notes behind this article, documenting the experiments I ran, the failures I encountered, and the reasoning behind each debugging decision.</p>
<p>That will be followed by a detailed account of the contribution process itself: tracing the issue to its root cause, developing the patch, designing and running regression tests, validating the fix, and working toward an upstream pull request.</p>
]]></content:encoded></item><item><title><![CDATA[Building an LSM-Tree MemTable in C++: Engineering Discipline Behind the Implementation]]></title><description><![CDATA[Five principles for designing functions, controlling access, reducing redundancy, reasoning about correctness, and using LLMs without outsourcing…]]></description><link>https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-engineering-discipline-behind-the-implementation</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-engineering-discipline-behind-the-implementation</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Wed, 07 Oct 2026 10:57:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/0c8663af-92f2-4fd7-b987-fa9122225196.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>So I recently completed Stage 2 of building the MemTable of my storage engine(here's a <a href="https://github.com/Kensei561/Building_LSM/blob/main/src/memtable.h">link</a> to it).</p>
<p>The following are the 5 things I learned, plus a bonus tip, while completing Stage 2:</p>
<ul>
<li>Inquire into the purpose of each fn and variable that the LLM tells you to define.</li>
</ul>
<p>When writing a fn from scratch, we can end up endlessly rewriting the function signature and still write code that doesn't fulfil its intended role. This vagueness stems from not clearly knowing that role. Hence, clarify the scope of the role that is to be fulfilled by each of the functions and variables designated by the LLM.</p>
<p>Consider yourself neither superior nor inferior to the LLM; it is capable both of making mistakes and providing us with valuable methods. Question each of its decisions.</p>
<ul>
<li>Before defining fns or variables, check the accessibility label you’re putting them under(private, public).</li>
</ul>
<p>In the members we define, we must be thoughtful with respect to who must have access and what must remain private.</p>
<ul>
<li>Take a pen and paper, and state the logic of the mechanisms being used in your fns.</li>
</ul>
<p>Rewriting code can become messy. Code must only be written as a reflection of the logic you have already cleared out on paper. This is an insight only understood upon implementing it first-hand.</p>
<ul>
<li>Aim at reducing redundancy(for example: avoid recalculation and copies by defining variables that store the calculated parameters and passing values by reference, respectively).</li>
</ul>
<p>I suppose this is intelligence only built after accumulating some experience.</p>
<ul>
<li>Correctness trumps performance on edge cases.</li>
</ul>
<p>Validity is more innate of a requirement than speed.</p>
<p>The above five points can be summarised into one robust rule: <em>be more thoughtful with the smallest of the decisions you make; be</em> <em><strong>Socratic.</strong></em> One may use these insights as a checklist when writing code for their own projects.</p>
<hr />
<p>Bonus tip:</p>
<ul>
<li>Shallow copying of an owning raw pointer that points to a heap-allocated resource will result in a double-free error upon destruction. Hence, the copy assignment and the copy constructor need to be deleted for such instances.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Building an LSM-Tree MemTable in C++: Small-Buffer Optimization and Heap Allocation for Lookup Keys]]></title><description><![CDATA[A C++ design investigation into variable-length keys, stack-versus-heap allocation, CPU cache behavior, and the performance trade-offs of temporary search…]]></description><link>https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-small-buffer-optimization-and-heap-allocation-for-lookup-keys</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/building-an-lsm-tree-memtable-in-c-small-buffer-optimization-and-heap-allocation-for-lookup-keys</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Sun, 04 Oct 2026 13:48:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/d55bede2-1308-4933-b9ca-91e9e1c8240d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>To implement a <code>Get()</code> fn for my MemTable, I require a dummy node with which the <code>Comparator</code> compares nodes to decide whether the current node being read matches the one being searched. I named this dummy variable <code>LookupKey</code>. When the user provides the system with the <code>UserKey</code> and the <code>trailer</code>, we store that by serialising them into a char array. I gave the following pseudocode for it:</p>
<pre><code class="language-cpp">LookupKey(std::string &amp;userkey, uint64_t sequence_no)
        {
            //make base128 varint for the length of the userkey, put into space_
            //kstart_ += size in bytes of the varint
            //in the remaining space put userkey and trailer using memcpy
            //end_ = kstart_ + decoded_varint + 8;
        }
</code></pre>
<p>To this, the LLM (which I’m using in the <a href="https://theshadowbyte.hashnode.dev/building-with-ai-without-outsourcing-the-thinking">6-Stage process</a>) responded with:</p>
<blockquote>
<p>If <code>userkey.size()</code> exceeds this capacity, <code>memcpy</code> will trigger stack corruption. The constructor must verify the required memory footprint. If it exceeds 200 bytes, it must dynamically allocate memory (<code>new char[]</code>), update <code>start_</code> to point to the heap, and implement <code>~LookupKey()</code> to <code>delete[]</code> the memory if a dynamic allocation occurred.</p>
</blockquote>
<p>But wouldn’t that cause heap fragmentation and consequently cache misses? We could have put a length limit when taking in the <code>UserKey</code>, but that would be invalid in cases where the key has a fixed length, such as a phone number. But aren’t cache misses a concern when the CPU has to consecutively retrieve memory? LookupKey, being a temporary search object, won’t be retrieved much I guess. Is that why it’s okay for us to dynamically allocate memory?</p>
<p>This is why it is justified for our code to use heap allocations for arbitrarily large keys:</p>
<ul>
<li><p>First of all, when it comes to edge cases, correctness must trump performance, as validity is more innate of a requirement than speed.</p>
</li>
<li><p>And my guess was correct—because <code>LookupKey</code> is destroyed at the exit of the <code>Get()</code> call, any dynamically allocated memory block is immediately returned to the thread-local allocator cache. It does not linger in memory or degrade the long-term memory layout of the LSM-tree.</p>
</li>
<li><p>Also, a single 200+ byte key already spans multiple 64-byte CPU cache lines regardless of whether it resides on the stack or heap. Cache hits are hence inevitably impossible.</p>
</li>
<li><p>Lastly, SkipList traversal requires O(log N) key comparisons per search. Memory allocation for <code>LookupKey</code> executes exactly once per <code>Get()</code> call. For large keys, sequentially reading and comparing(using <code>memcmp</code>) hundreds of bytes across dozens of node evaluations consumes more CPU cycles than a single dynamic allocation. Hence, the bottleneck for large key queries shifts from the memory allocation overhead to the <code>memcmp</code> byte-by-byte comparison overhead in the <code>SkipList</code> search traversal.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — Phase 1: Finding the First Bad Commit with Git Bisect]]></title><description><![CDATA[Automating a memory-leak regression test across DuckDB versions with a Bash test harness]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-1-finding-the-first-bad-commit-with-git-bisect</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-phase-1-finding-the-first-bad-commit-with-git-bisect</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Fri, 02 Oct 2026 15:29:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/41fcd401-146f-4c06-917f-105bf23a5759.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>With the build being successful, the next step to take was to use git bisect to identify the first bad commit:</p>
<pre><code class="language-powershell">git bisect start
git bisect good v1.4.4
git bisect bad v1.5.2
</code></pre>
<p>I used the following automated evaluation script (<code>bisect_test.sh</code>) to detect the exact commit where the memory threshold violation was taking place:</p>
<pre><code class="language-powershell">#!/bin/bash
make RELEASE_BUILD=1 &gt; /dev/null 2&gt;&amp;1
rm -f test.db test.db.wal
./build/release/duckdb test.db &lt; repro.sql &gt; output.txt 2&gt;&amp;1
grep -q "Out of Memory Error" output.txt
if [ $? -eq 0 ]; then
    exit 1
else
    exit 0
fi
</code></pre>
<ul>
<li><p><code>/dev/null</code> is a special Linux file that accepts data and immediately discards it. <code>2&gt;&amp;1</code> means “stderr (2) → wherever stdout (1) is currently going”. Since stdout is already going to <code>/dev/null</code>, stderr also gets sent there. The result of that line is that you won't see the build output, even if the build fails.</p>
</li>
<li><p><code>grep -q "Out of Memory Error" output.txt</code> searches <code>output.txt</code> for <code>Out of Memory Error</code>. <code>-q</code> means quiet mode; it only prints whether the matching line was found, rather than the line itself.</p>
</li>
<li><p><code>$?</code> stands for the exit status of the immediately preceding command. <code>grep</code> returns 0 when it finds the given text, and 1 otherwise. OOM occurrence means that the test failed; hence, if the OOM message wasn't found, the script exits with <strong>0</strong>, meaning the test passed.</p>
</li>
</ul>
<p>Using grep’s exit status as a signal for <code>git bisect run ./bisect_test.sh</code>, the automated bisect identified the first commit satisfying the test harness's definition of ‘bad’. I subsequently examined the harness itself and identified an important limitation: it validated only the presence of the expected OOM message, not whether the build and DuckDB execution actually succeeded(more on this in the P.S. section).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/c0cc2e54-5f5c-4c40-af5c-4b78060dc2d6.png" alt="" />

<p>Using the reproduction script given on the <a href="https://github.com/duckdb/duckdb/issues/25162">issue page</a>, I was able to reproduce the error:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/ec2d8fc9-46dd-4b7e-81d8-dbb4ca74100e.png" alt="" />

<hr />
<h3>PS:</h3>
<p>There is one risk to the evaluation script that came to my mind after the reproduction of the OOM: since the if conditions in the script only check grep’s status, and not DuckDB's actual exit status, there could be a scenario where <code>"Out of Memory Error"</code> isn't present, but the line <code>./build/release/duckdb test.db &lt; repro.sql &gt; output.txt 2&gt;&amp;1</code> fails for some completely different reason, and the script returns <code>0</code> and tells <code>git bisect</code> that the commit is good simply because of the absence of the aforementioned OOM keywords.</p>
]]></content:encoded></item><item><title><![CDATA[Building with AI Without Outsourcing the Thinking]]></title><description><![CDATA[A six-stage, LLM-assisted development process for specification, interface design, invariant-driven testing, implementation, sanitizer debugging, and…]]></description><link>https://theshadowbyte.hashnode.dev/building-with-ai-without-outsourcing-the-thinking</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/building-with-ai-without-outsourcing-the-thinking</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Tue, 29 Sep 2026 09:12:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/0401084a-bf55-4bc9-a587-56af7e2220a4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When building a project, you can mistakenly lean too far toward one of the two extremes with respect to how much you use AI.</p>
<p>First is being a purist, i.e. renouncing LLMs altogether and scraping documentation, subreddits, YouTube, and other such platforms for knowledge. As I have experienced, this can get very time-consuming, especially for a beginner, i.e. someone who is not familiar with this method of acquiring the required knowledge, and can become stressful when one is not able to get the answers to their questions even after spending that much time.</p>
<p>Second is being unhealthily reliant on AI; for example, asking for the complete implementation code from AI, reverse engineering it to understand what is happening, and mistaking the sequence of these actions for ‘building’. Such a person cannot even be considered a programmer, given that they haven’t opened a code editor, encountered a single error, or even run one of their ‘built’ implementation files.</p>
<p>Personally, I perceive AI as ultimately a source of thoughts, one which can take both the form of a teacher and, most importantly, an interrogator. I thus want AI to play those exact roles—question my every decision to ensure that each one is well thought out, and acquaint me with concepts or syntax which I do not already know, which are required to build the project.</p>
<p>Thus, driven by the desire to be thorough but not primal, I use the following 6-stage process to navigate the building:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>You do</th>
<th>LLM does</th>
<th>Exit criterion</th>
</tr>
</thead>
<tbody><tr>
<td>1. Constraint specification</td>
<td>State the problem in your own words — what must hold, what can be assumed, what's out of scope</td>
<td>Clarifying questions only — surfaces constraints you didn't state, never proposes a solution shape</td>
<td>You can restate the constraint list without notes</td>
</tr>
<tr>
<td>2. Interface design</td>
<td>Draft method signatures, types, ownership — even if wrong</td>
<td>Adversarial critique only — names missing cases, ambiguous ownership, races the interface doesn't prevent — never supplies a corrected signature</td>
<td>Every critique gets answered by you revising it, not by the LLM revising it</td>
</tr>
<tr>
<td>3. Invariant + fault harness</td>
<td>Write the invariants that must hold and the cases that would violate them, before any implementation exists</td>
<td>Reviews for completeness — "what fault isn't covered yet" — never writes test bodies</td>
<td>Fault list covers at least one case per invariant</td>
</tr>
<tr>
<td>4. Blind implementation</td>
<td>Write the code, alone</td>
<td>Compiler/static-analyzer role only — names the category and location of a problem, never the fix</td>
<td>Compiles, and you can explain every line without the LLM having generated any of it</td>
</tr>
<tr>
<td>5. Sanitizer-driven iteration</td>
<td>Run ASan/UBSan/TSan; propose your own fix per finding</td>
<td>Interprets sanitizer <em>output</em> only if you're stuck after your own attempt — explaining a trace is fair, fixing a race is not</td>
<td>Sanitizer-clean, and you can explain why each fix works, not just that it did</td>
</tr>
<tr>
<td>6. Socratic defense</td>
<td>Answer, unprepared</td>
<td>Interviewer only — invents new scenarios not in the original spec, tests generalization over memorization</td>
<td>You defend a scenario the LLM invented on the spot, not one you'd already reasoned through</td>
</tr>
</tbody></table>
<p>This makes the entire process hands-on and makes sure I do not bypass the deepest, nastiest depths that programming can expose one to, and instead forces my own analytical thinking to find a way out.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR2 — From One Issue to the Next PR Candidate]]></title><description><![CDATA[Tracing a DuckDB memory leak from ON CONFLICT ... DO UPDATE through ART index updates and checkpoint cleanup]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr2-from-one-issue-to-the-next-pr-candidate</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr2-from-one-issue-to-the-next-pr-candidate</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Sat, 26 Sep 2026 08:04:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/6b54cc06-4c62-4298-827d-eeeaf5774409.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>For the reasons mentioned in Part 3 of <a href="https://theshadowbyte.hashnode.dev/duckdb-pr1-phase-2-investigating-the-double-free">my last blog</a>, I chose to look for a new PR candidate issue. The following is the one I chose(here's a <a href="https://github.com/duckdb/duckdb/issues/25162">link</a> to it):</p>
<p>Before we describe the problem, I would like to share some keywords that are necessary to understand it.</p>
<ul>
<li><p><strong>Memory Leak:</strong> Occurs when a program allocates RAM, finishes using it, but forgets to deallocate it. The unreferenced RAM remains marked as "in use" indefinitely.</p>
</li>
<li><p><strong>Database Index:</strong> Linear searching is expensive for a large database. A database index is an additional data structure(which mirrors the database's contents) maintained alongside a database to make specific data lookups faster. B-tree, hash table, skip list, etc. are possible implementations.</p>
<p>DuckDB uses an index structure called an Adaptive Radix Tree (ART).</p>
</li>
<li><p><strong>Upsert (</strong><code>ON CONFLICT ... DO UPDATE</code><strong>):</strong> A database command instructing: "Insert this new row. If a row with the same primary key already exists, overwrite the old row instead."</p>
</li>
<li><p><strong>Version Tracking &amp; Undo Buffers:</strong> When a database modifies or overwrites a row, it creates temporary copies (versions) of the old data in RAM so concurrent read operations are not interrupted.</p>
</li>
<li><p><strong>Checkpointing:</strong> The background process that takes modified data sitting in fast RAM, writes it permanently to Disk, and deallocates the temporary RAM blocks used during the modifications.</p>
</li>
</ul>
<p>So, here’s the issue:</p>
<h3>Normal Operation (DuckDB v1.4.4)</h3>
<ol>
<li><p>An <code>ON CONFLICT ... DO UPDATE</code> query executes.</p>
</li>
<li><p>The database allocates RAM to hold updated row versions and update the index tree.</p>
</li>
<li><p>A <strong>Checkpoint</strong> triggers: modified rows are written to disk.</p>
</li>
<li><p>The database deallocates the obsolete version buffers and old index nodes from RAM.</p>
</li>
<li><p><strong>Result:</strong> Total memory usage stays flat at ~50 MiB over millions of operations.</p>
</li>
</ol>
<h3>Defective Operation (DuckDB v1.5.x)</h3>
<p>1, 2 and 3 execute the same as DuckDB v1.4.4.</p>
<ol>
<li><p><strong>The Bug:</strong> A code omission in v1.5.x skips the deallocation step for the temporary nodes used by the index as scaffolding and version-tracking structures created during conflict resolution.</p>
</li>
<li><p><strong>Result:</strong> The system holds onto ~6 MiB of dead RAM per update cycle. Memory consumption rises continuously until RAM is exhausted, causing an Out-Of-Memory (OOM) process crash.</p>
</li>
</ol>
<h3><strong>Downstream Mitigation (SpiceAI):</strong></h3>
<p>A third-party project using DuckDB observed these OOM crashes on v1.5.x. They changed their configuration file to bundle DuckDB v1.4.4 instead. This circumvents the bug for their users but does not repair the code.</p>
<h3><strong>Upstream Resolution (DuckDB Engine):</strong></h3>
<p>The mitigation made it clear that the actual defect resides inside DuckDB's C++ source code between version tags <code>v1.4.4</code> and <code>v1.5.2</code>. Fixing it requires identifying the missing deallocation call in the checkpoint or index vacuum logic and adding a pointer reset, an explicit <code>delete</code>, or a missing traversal loop to free orphaned nodes.</p>
<p>I will shortly document all the details regarding the investigation and, hopefully, the execution of the solution to this issue. Until then, stay tuned!</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR1 — Phase 2: Investigating the Double-Free]]></title><description><![CDATA[..reproducing a DuckDB memory-safety bug with AddressSanitizer, automated test matrices, controlled memory and thread limits, and historical-version…]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr1-phase-2-investigating-the-double-free</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr1-phase-2-investigating-the-double-free</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Wed, 23 Sep 2026 15:23:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/23d7fb52-e567-4370-8d2c-58503898190d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Part 1 — Preparing the Reproduction Workload</h2>
<p>With the build successful, I began the process of reproducing the double free.</p>
<h3>Creating a Reproducible DuckDB Workload</h3>
<p>To prevent the system from reproducing ~900MB of data during each test, I generated the <code>output.parquet</code> file and stored it in the DuckDB folder. I made a file named <code>repro.sql</code>, which, copied from the original, contained the following:</p>
<pre><code class="language-sql">SET memory_limit = '6.4g';

FROM 'output.parquet'
SELECT
    FIRST(columns(*)),
    LIST(value ORDER BY day)
GROUP BY id
ORDER BY id, day;
</code></pre>
<h3>Configuring AddressSanitizer for Targeted Diagnostics</h3>
<p>I experimented with:</p>
<pre><code class="language-shell">ASAN_OPTIONS=verbosity=1
</code></pre>
<p>This generated large amounts of runtime diagnostic noise, including:</p>
<pre><code class="language-shell">failed to intercept ...
Unregistered root region ...
poisoning/unpoisoning ...
</code></pre>
<p>These are ASan runtime diagnostics, not proof of the target bug; the ASan output required a change.This is the configuration I finally chose to go with:</p>
<pre><code class="language-powershell">ASAN_OPTIONS='symbolize=1:halt_on_error=1:abort_on_error=1'
</code></pre>
<h3>Capturing and Verifying ASan Test Results</h3>
<p>Further, I established the following shell sequence:</p>
<pre><code class="language-powershell">set -o pipefail
./build/debug/duckdb &lt; repro.sql 2&gt;&amp;1 | tee asan-run-01.log
echo "exit code: ${PIPESTATUS[0]}"
</code></pre>
<ul>
<li><p><code>set -o pipefail</code> tells Bash to treat the whole pipeline as failed if any command in that pipeline fails.</p>
</li>
<li><p>&lt; takes the contents of the RHS and connects them to LHS's stdin.</p>
</li>
<li><p>&gt; takes the contents of the RHS and connects them to LHS's stdout.</p>
</li>
<li><p>The keyword <code>tee</code> does two things simultaneously:</p>
<ol>
<li><p>It shows what’s on the other side of the pipe( | ) on the terminal.</p>
</li>
<li><p>Store the same into a file mentioned after <code>tee</code>.</p>
</li>
</ol>
</li>
<li><p><code>echo "exit code: ${PIPESTATUS[0]}"</code> gives the exit status of the first command(<code>./build/debug/duckdb &lt; repro.sql 2&gt;&amp;1</code>) in the pipeline.</p>
</li>
</ul>
<p>The following flowchart summarises what this pipeline does:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/91a05b63-46c2-4d85-9908-e9423467207f.png" alt="" />

<h2>Part 2 — Establishing the Failure Conditions</h2>
<h3>Testing Memory Limits</h3>
<p>I tried setting 6.4GB as the memory limit, but was unable to reproduce even the OOM. I lowered it down a notch, and OOM was successfully reproduced:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/d65db076-f8a8-4d9d-9aeb-099e01a04ed4.png" alt="threads = 2, although the OOM occured even without parallel execution i.e. at threads = 1." />

<h3>Varying Memory Limits and Thread Counts</h3>
<p>Though even now, I was unable to reproduce the double-free error. I thus decided to experiment by varying the number of threads and the memory limit, ranging from 1 to 20 threads and 512MB to 4GB(since 6.4GB was the verified upper limit).</p>
<p>I executed the variations for 512MB and 1GB:</p>
<ul>
<li><p>memory_limit = ‘512MB’</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/ba8186b9-2ca3-447b-8d6f-f0acfe5418b8.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/29ff1e4a-7aca-408e-b4d5-39f4e6a534e2.png" alt="threads = 20" style="display:block;margin:0 auto" />

<p>memory_limit = ‘1GB’</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/855e8947-b3ff-4c1b-a81e-d1e9fdbb683c.png" alt="threads = 1" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/9d7dfeee-bdc0-4e3a-a2ef-8b73306b224f.png" alt="threads = 20" /></li>
</ul>
<h3>Repeating Configurations Instead of Testing Once</h3>
<p>Then I remembered a segment of the issue that was submitted on GitHub:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/7db1f960-b57e-4d92-ae9e-504b58bc8e5c.png" alt="" style="display:block;margin:0 auto" />

<p>I, on the other hand, was running each configuration only once.</p>
<h3>Automating the Reproduction Matrix</h3>
<p>To run each configuration multiple times would require manually creating and executing each iteration. This would be time-consuming. I automated the process using simple <code>for</code> loops in a shell script(here’s a <a href="https://github.com/Kensei561/DuckDB-Drives/blob/main/scripts/run_matrix.sh">link</a> to the code).</p>
<p>Since 512MB and 1GB were already manually gone through, I chose to execute only the case of 2 and 4GB:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/db4aad9f-b09d-4b85-95f0-0e776d46e40a.png" alt="" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/747000f4-65c3-4e9f-b48f-b5b7f6ab3d1a.png" alt="" />

<h2>Part 3 — Evaluating the Reproduction Results</h2>
<h3>The Double-Free Remained Unreproduced</h3>
<p>As can be seen, unlike the OOM exception, I couldn’t reproduce the double-free. There were one of the two choices I could make from this point onwards:</p>
<ol>
<li><p>Build an earlier version of DuckDB and try on that version all the tests that I did on the current one.</p>
</li>
<li><p>Abandon this issue as a whole and find and examine another one.</p>
</li>
</ol>
<h3>Evaluating the Upstreaming Path</h3>
<p>Reproducing the issue against the previous DuckDB version would require rebuilding the older environment and repeating the configuration work from scratch.</p>
<p>More importantly, the issue was posted a year ago; the current DuckDB codebase has since moved forward. Perhaps the bug existed in the affected release, but the relevant cleanup behaviour has changed since then. Hence, perpetually testing only the latest source would not necessarily reproduce the historical issue.</p>
<p>Also, a patch developed against the older revision would therefore need to be reworked against the current version before it could serve as a meaningful upstream contribution.</p>
<h3>Decision: Moving to a New PR Candidate</h3>
<p>Given the cost of reproducing the historical environment and the additional work required to carry any resulting fix forward, I decided to stop pursuing this issue and find a new PR candidate.</p>
<hr />
<h2>P.S.</h2>
<p>While performing the ASan tests manually, the cursor would hang upon executing my pipeline sequence. To check whether DuckDB was still executing, I would run the following command in another WSL terminal:</p>
<pre><code class="language-powershell">ps aux | grep '[d]uckdb'
</code></pre>
<p>There is a question that would very commonly arise in the minds of anybody looking at the above command: why are square brackets ([ ]) involved in it?</p>
<p>The square brackets are a small regex trick. <code>[d]</code> is a bracket expression matching the character <code>d</code>, so <code>[d]uckdb</code> still matches <code>duckdb</code>. The difference is that the <code>grep</code> process itself contains the literal pattern <code>[d]uckdb</code> in its command line. It therefore does not contain the contiguous string <code>duckdb</code>, preventing <code>grep</code> from matching itself.</p>
<p>It is a tiny trick, but useful enough to remember ^_^.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR1 — Phase 1: Setting Up the Build]]></title><description><![CDATA[..from MSYS2 CLANG64 to a Linux-Native WSL2 Build: Troubleshooting Sanitizers, CMake, and Ninja.]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr1-phase-1-setting-up-the-build</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr1-phase-1-setting-up-the-build</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Sun, 20 Sep 2026 11:21:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/320f94e5-e347-4d85-82db-cec5e5bb8ff4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Part 1 — Build Configuration</h2>
<h3>The MSYS2 CLANG64 Attempt</h3>
<p>I have been using an MSYS2 CLANG64 terminal for my LSM tree building and compiling, so I made the mistake of using the same terminal for building DuckDB.</p>
<p>The initial attempt was to build DuckDB with sanitizers using the following command:</p>
<pre><code class="language-shell">CC=clang CXX=clang++ GEN=ninja BUILD_SANITIZERS=1 make debug
</code></pre>
<p>The build encountered several toolchain and compatibility problems, including:</p>
<pre><code class="language-shell">lld: error: unknown argument: --exclude-libs
</code></pre>
<p>and POSIX-related compilation problems such as:</p>
<pre><code class="language-shell">getpid undeclared
</code></pre>
<p>DuckDB's sanitizer build configuration thus exposed compatibility problems when I tried to use it from the MSYS2 CLANG64 terminal.</p>
<p>Another option was to shift the project into a native WSL2/Ubuntu environment, so I chose to do that. Further, I installed the required toolchain using the following command:</p>
<pre><code class="language-powershell">sudo apt update
sudo apt install -y build-essential cmake ninja-build clang llvm git
</code></pre>
<h3>The AddressSanitizer Runtime Problem</h3>
<p>After moving to WSL2, the sanitizer build reached the linking stage but failed to locate AddressSanitizer runtime libraries:</p>
<pre><code class="language-shell">cannot find ... libclang_rt.asan_static.a
cannot find ... libclang_rt.asan.a
cannot find ... libclang_rt.asan_cxx.a
</code></pre>
<p>I initially thought that the Clang/ASan installation might be incomplete. But they were eventually found. I ran a standalone ASan program successfully with:</p>
<pre><code class="language-shell">clang++ -fsanitize=address /tmp/asan_test.cpp -o /tmp/asan_test
</code></pre>
<p>This established that:</p>
<ul>
<li><p>Clang was installed correctly.</p>
</li>
<li><p>compiler-rt was installed.</p>
</li>
<li><p>AddressSanitizer itself worked.</p>
</li>
<li><p>The failure was specifically related to where DuckDB's Clang build expected the sanitizer runtime libraries to be located.</p>
</li>
</ul>
<p>I decided to discontinue that problematic Clang configuration for a change; GCC's sanitizer runtime was used instead of the problematic Clang/LLVM runtime lookup. That fixed the aforementioned issue.</p>
<h2>Part 2 — Diagnosing "No Space Left on Device"</h2>
<p>When I tried to build now, it failed with:</p>
<pre><code class="language-shell">Fatal error: 
... .o: No space left on device
</code></pre>
<h3>Verifying Available Disk Space</h3>
<p>At first, this appeared to be a conventional disk-space problem. I checked the available Windows filesystem space using:</p>
<pre><code class="language-powershell">df -h /mnt/c
</code></pre>
<p>It showed:</p>
<pre><code class="language-shell">Filesystem Size Used Avail Use% Mounted on 
C:\        476G 341G  135G  72%     /mnt/c
</code></pre>
<p>So there were approximately 135 GB of free space on <code>C:</code>. That made a conventional “disk is full” explanation unlikely.</p>
<h3>Testing the Filesystem</h3>
<p>To test this directly, I then attempted to write a 1 GB file to the exact filesystem containing the DuckDB source:</p>
<pre><code class="language-shell">dd if=/dev/zero \ 
of="/mnt/c/Users/Akshat Kumar/duckdb/test_space.tmp" \ 
bs=1M count=1024
</code></pre>
<p>It reported:</p>
<pre><code class="language-shell">1024+0 records in 
1024+0 records out 
1073741824 bytes (1.1 GB, 1.0 GiB) copied
</code></pre>
<p>So it’s clear that WSL could successfully allocate and write another <strong>1 GiB</strong> on <code>/mnt/c</code>. Why did it then show “No space left on device”?</p>
<h3>Moving the Build to the Native Linux Filesystem</h3>
<p>The problem was this:</p>
<p>My WSL build was still located on the Windows filesystem. So WSL had to translate Linux filesystem operations into Windows filesystem operations.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/6c8cf141-3914-4d3b-83da-4f013da0cf27.png" alt="" />

<p>Every operation such as <code>open()</code>, <code>close()</code>, <code>stat()</code>, <code>mkdir()</code>, <code>rename()</code>, <code>write()</code>, <code>fsync()</code> has to cross that boundary.</p>
<p>A large C++ build(such as that of DuckDB) performs huge numbers of these operations, because every compilation unit produces intermediate files, object files, dependency information, temporary files, etc. Ninja was similarly doing many of them concurrently.</p>
<p>The fix was to build on the native Linux filesystem. Doing this completely circumvented the Windows filesystem translation layer, which was previously involved.</p>
<hr />
<p>An expected conflict arose as a result. CMake detected that the existing <code>CMakeCache.txt</code> belonged to a different build location/environment; the old build directory had previously been generated from the Windows environment. CMake therefore had cached Windows paths such as:</p>
<pre><code class="language-powershell">C:/Users/Akshat Kumar/duckdb/build/debug
</code></pre>
<p>while the new WSL environment referred to the project through paths such as:</p>
<pre><code class="language-shell">/home/akshat/duckdb
</code></pre>
<p>I hence removed the stale generated build directory using <code>rm -rf build/debug</code> and regenerated it from the WSL environment.</p>
<h2>Part 3 — Reducing Ninja Concurrency</h2>
<p>I initiated the build using:</p>
<pre><code class="language-powershell">GEN=ninja BUILD_SANITIZERS=1 make debug
</code></pre>
<p>CMake configured successfully:</p>
<pre><code class="language-shell">Configuring done
Generating done
Build files have been written to:
/home/akshat/duckdb/build/debug
</code></pre>
<p>Ninja then appeared to remain at:</p>
<pre><code class="language-shell">[0/425] Building CXX object.../ub_duckdb_expression_binders.cpp.o
</code></pre>
<p>It didn’t move forward. To track the status of the build, i.e. whether it was progressing or whether it had frozen, I tried to open other terminals, but for some reason, they would hang the moment I would attempt to enter WSL using commands like <code>wsl</code> and <code>wsl -d Ubuntu</code>.</p>
<h3>Why the Build Appeared to Hang</h3>
<p>Upon examining the terminal, I saw this amongst the lines generated during the build:</p>
<pre><code class="language-powershell">-- Build workers: compile=12
</code></pre>
<p>-- Build workers: compile=12</p>
<p>The build repeatedly struggled around <code>ub_duckdb_expression_binders.cpp.o</code> because the large DuckDB C++ translation units, debugging information, and sanitizer instrumentation were making each compiler process memory-intensive.</p>
<h3>Reducing Compile Workers with Ninja</h3>
<p>Instead of continuing through the Makefile wrapper, I invoked the generated Ninja build directly with reduced concurrency:</p>
<pre><code class="language-powershell">cd ~/duckdb/build/debug
ninja -j 2
</code></pre>
<p>This restricted Ninja to two concurrent build jobs rather than the previous 12 compile workers.</p>
<p>The build <em>then</em> completed successfully.</p>
]]></content:encoded></item><item><title><![CDATA[DuckDB PR1 — Selecting a PR Candidate]]></title><description><![CDATA[Investigating C++ stack unwinding, destructor cleanup, aggregate-state lifetime, and memory ownership during DuckDB OOM handling]]></description><link>https://theshadowbyte.hashnode.dev/duckdb-pr1-selecting-a-pr-candidate</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/duckdb-pr1-selecting-a-pr-candidate</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Mon, 14 Sep 2026 05:56:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/582c81a9-6121-4b24-9ae3-76e6ec4fb21e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I have finally shortlisted a PR candidate(here's a <a href="https://github.com/duckdb/duckdb/issues/19391">link</a> to it).</p>
<p>Before I describe the problem, I would like to acquaint you with some keywords that are included in the description of the issue, which I feel are quite significant for understanding it.</p>
<ul>
<li><p>Stack Unwinding</p>
<p>Say a function runs and causes an exception. To find a handler for the error, the C++ runtime goes backwards line by line until it reaches one for that exception(if no handler exists, the program terminates). As it unwinds, it destroys the already-constructed local/automatic objects whose scopes are being exited. This process is called stack unwinding. Their destructors then release the resources those objects own, such as memory, file handles, locks, etc.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/e8e8687f-0e15-436e-9e52-03f85d799af9.png" alt="" />

<p>Eg:</p>
<pre><code class="language-cpp">void foo() {
    MyObject obj;
    do_something();   // throws an exception
}
</code></pre>
<p>C++ walks backward through the chain of function calls, destroying the local objects that have already been constructed, until it finds code that catches the exception.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/bf9431e8-563a-47fc-9eb6-fc085d498ff7.png" alt="" />
</li>
<li><p>OOM(Out-of-Memory)</p>
<p>An OOM condition occurs when a program requests more memory than the system or the program's configured memory limit allows it to use.</p>
</li>
<li><p>Double-free</p>
<p>This exception occurs when a destructor attempts to free memory that has already been freed.</p>
</li>
</ul>
<p>So, here’s the issue:</p>
<ul>
<li><p>The provided SQL query groups data and builds lists of values. DuckDB stores this in a temporary dictionary structure called a <strong>GroupedAggregateHashTable</strong>.</p>
</li>
<li><p>The query intentionally generates excessive data, violating the artificial 6.4GB memory constraint. This triggers an OOM. Stack unwinding begins, and destructors are run to free all active pointers before the operation fully aborts.</p>
</li>
<li><p>During this emergency teardown sequence, the destructor for the aggregate states (the lists and strings) executes a free operation on its memory. Due to a structural flaw in how DuckDB handles OOM aborts, a secondary cleanup path also attempts to free those exact same pointers. This executes the fatal double-free.</p>
</li>
</ul>
<p>The solution is simply the following:</p>
<p>In C++, attempting to free a <code>nullptr</code> is safely ignored by the runtime. Setting the pointer to null will thus sever the connection to the reclaimed memory and neutralise the double-free hazard. That is the core change that I will effectively implement to fix the issue.</p>
<p>I will shortly document the entire process of the execution of my solution. Until then, stay tuned!</p>
]]></content:encoded></item><item><title><![CDATA[Fixing the Silent Data Race]]></title><description><![CDATA[My skiplist.h code had a bug capable of causing data races at higher throughput. Initially, when I ran my test harness with just two threads, each with one…]]></description><link>https://theshadowbyte.hashnode.dev/fixing-the-silent-data-race</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/fixing-the-silent-data-race</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Fri, 11 Sep 2026 06:55:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/16f31a11-69e9-4a9a-94a8-de2b6e875334.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>My skiplist.h code had a bug capable of causing data races at higher throughput.</p>
<p>Initially, when I ran my test harness with just two threads, each with one insert function, it compiled and ran successfully. I then increased the number to 10000 insertions per thread to see its performance, attempting to witness a potential hardware collision. And I did. It gave me a long output, the summary of which was:</p>
<pre><code class="language-powershell">SUMMARY: ThreadSanitizer: data race src/skiplist.h:37 in SkipList&lt;int, std::__cxx11::basic_string&lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt; &gt;::Insert(int const&amp;, std::__cxx11::basic_string&lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt; const&amp;)
==================
ThreadSanitizer: reported 4 warnings
</code></pre>
<p>I checked line 37 and eventually understood what the problem was—candidate.value not being an atomic variable meant it was capable of being written to simultaneously by two distinct writing threads.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">The fix is to make the value variable atomic.</mark></p>
]]></content:encoded></item><item><title><![CDATA[Completing my WSL installation]]></title><description><![CDATA[To use TSan, I required a working WSL terminal. But the moment I opened it, it would instantly crash. This is how I got it to work(all of the following was…]]></description><link>https://theshadowbyte.hashnode.dev/completing-my-wsl-installation</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/completing-my-wsl-installation</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Thu, 10 Sep 2026 08:13:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/2b1b2a8c-3c5c-4c02-bd84-e1e105545478.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>To use TSan, I required a working WSL terminal. But the moment I opened it, it would instantly crash. This is how I got it to work(all of the following was conducted while running Windows PowerShell as an administrator).</p>
<p>Firstly, I tried:</p>
<pre><code class="language-shell">wsl
</code></pre>
<p>It showed:</p>
<pre><code class="language-shell">Windows Subsystem for Linux has no installed distributions.

You can resolve this by installing a distribution with the instructions below:

Use 'wsl.exe --list --online' to list available distributions and 'wsl.exe --install &lt;Distro&gt;' to install.
</code></pre>
<p>I tried:</p>
<pre><code class="language-shell">wsl --list --online
wsl --install -d Ubuntu
</code></pre>
<p>It would tell me to reboot to see the changes, but it would show the earlier (”<em>Windows subsystem.. ..to install.</em>”) output. Upon GPT’s suggestion, I put:</p>
<pre><code class="language-shell">wsl --status
</code></pre>
<p>It showed:</p>
<pre><code class="language-shell">Default Version: 2
WSL1 is not supported with your current machine configuration.
Please enable the "Windows Subsystem for Linux" optional component to use WSL1.
WSL2 is unable to start since virtualization is not enabled on this machine.
Please ensure the "Virtual Machine Platform" optional component is enabled and virtualization is turned on in your computer's firmware settings.

Enable "Virtual Machine Platform" by running: wsl.exe --install --no-distribution" 
</code></pre>
<p>I put:</p>
<pre><code class="language-shell">wsl --install --no-distribution
</code></pre>
<p>This required another reboot. After that, I checked whether my Virtualization was enabled(Go to Task Manager→Performance→CPU). Thankfully, it was enabled(if it’s not, you need to enable virtualisation in your BIOS/UEFI).</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">The next two commands really revealed what was inhibiting the wsl installation</mark>. For wsl to install, both of the following commands..</p>
<pre><code class="language-shell">Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform

Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
</code></pre>
<p>..must return <code>Status: Enabled</code> among its outputs. But in my case, for the latter one, it returned <code>Status: Disabled</code>. To enable it, I had to run:</p>
<pre><code class="language-shell">dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
</code></pre>
<p>The operation completed successfully. I rebooted the pc as per GPT’s suggestion. Finally, for the last time, when I put in:</p>
<pre><code class="language-shell">wsl --install -d Ubuntu
</code></pre>
<p>It started its proper installation and asked for my Linux username and password.</p>
]]></content:encoded></item><item><title><![CDATA[Deconstructing the DuckDB Buffer System.. Part 3]]></title><description><![CDATA[The final components that are yet to be dissected are blockhandle.cpp and bufferhandle.cpp(here's a link to it). The core aspects of the buffer to be…]]></description><link>https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-3</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-3</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Thu, 10 Sep 2026 05:41:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/447b074e-fa1a-4a8d-984f-94ec775e293f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The final components that are yet to be dissected are <code>blockhandle.cpp</code> and <code>bufferhandle.cpp</code>(here's a <a href="https://github.com/duckdb/duckdb/tree/v2.0-cyanoptera/src/storage/buffer">link</a> to it). The core aspects of the buffer to be understood by these files are the lifetime and the ownership relations(which essentially overlap) between the objects that constitute the buffer system. I feel like the understanding of these components really wraps up our mental model of how the buffer system works.</p>
<h3>Ownership:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/522d0062-f223-4ea1-b692-1f7baeea3fb4.png" alt="" />

<p>Here are some premises that helped me understand the ownership aspect of the components better.</p>
<ul>
<li><p>X owning Y implies that the destruction of the former will cause the destruction of the latter. X having access to Y means X has access to the allowed members of Y.</p>
</li>
<li><p><code>optional_ptr</code> is a DuckDB-defined pointer. It is a nullable, non-owning ptr.</p>
</li>
<li><p>The members <code>memory_p</code> and <code>&amp;memory</code> serve the role of giving ownership and access to a BlockMemory, respectively, to BlockHandle. <code>memory</code> is an alias, not a second object.</p>
</li>
</ul>
<p>Proceeding with the deconstruction, there were many questions I encountered. After much clarification and rigorous questioning, the following is what my understanding crystallised into:</p>
<blockquote>
<p>Who other than BlockHandle can own a BlockMemory object?</p>
</blockquote>
<p>From our current knowledge of the system, only BlockHandle, and possibly other objects that acquire a copy of <code>memory_p</code>, can own a BlockMemory object.</p>
<blockquote>
<p>What is a <code>FileBuffer</code>? What is the difference between a <code>FileBuffer</code> object and a Block?</p>
</blockquote>
<p>A block is a fixed-size unit/region that can represent ephemeral/intermediate data or data from persistent storage.</p>
<p>When DuckDB needs, say, Block X, it cannot directly operate on the disk bytes efficiently. It loads that block into RAM. That <strong>chunk of RAM containing the loaded bytes</strong> needs an object to represent it and provide other objects with functions to interact with this chunk. That object is a <code>FileBuffer</code><strong>.</strong></p>
<p>Summary:</p>
<p>A block is a fixed-size unit/region that can represent an ephemeral/ intermediate data or data from persistent storage. A <code>FileBuffer</code> is an object representing the RAM buffer that holds the block's bytes when that block is loaded into memory, along with metadata and operations related to that buffer.</p>
<blockquote>
<p>What is the difference between FileBuffer and BufferHandle? If FileBuffer is already the representative of the buffer in RAM occupied by a block, what is BufferHandle for?</p>
</blockquote>
<p>When a reader requests access to a block, that access is granted using an optional_ptr to the FileBuffer containing the block. This ptr is called BufferHandle. Pinning increases the reader count, while unpinning decreases it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/e1217efd-5a45-4a92-bccd-c02b97465f75.png" alt="" />

<blockquote>
<p>When a reader requests access, why is a shared_ptr to the BlockHandle given along with the optional ptr(BufferHandle) required to access the FileBuffer which holds the block?</p>
</blockquote>
<p>Readers, using the part of BufferHandle that contains a shared_ptr to BlockHandle, collectively own the BlockHandle. The collective lifetime of the BufferHandles thus corresponds with that of BlockHandle and consequently, FileBuffer or the block itself.</p>
<hr />
<h3>Lifetime:</h3>
<p>The following are some premises that I uncovered in the process of deconstructing these components:</p>
<ul>
<li><p><code>block_id</code>s are given to blocks based on whether or not they represent regions from persistent storage or intermediate/short-lived data generated during active query execution (e.g., overflow data from a massive sort or join operation). A threshold is defined(<code>MAXIMUM_BLOCK</code>). Blocks with block_ids less than this threshold represent the previous type, while those with IDs greater than the threshold represent the latter.</p>
</li>
<li><p>Readers are granted access to a block using <code>BufferHandle</code>(an optional pointer is a part of the <code>BufferHandle</code> object). BufferHandle also has a shared pointer to <code>BlockHandle</code>. The collective lifetime of the BufferHandles thus corresponds with that of <code>BlockHandle</code> and consequently, <code>FileBuffer</code> or the block itself.</p>
<p><mark class="bg-yellow-200 dark:bg-yellow-500/30">Assumption: BufferHandles are the only strong owners of a </mark> <code>BlockHandle</code><mark class="bg-yellow-200 dark:bg-yellow-500/30">.</mark></p>
</li>
<li><p><code>CanUnload()</code> is a function that determines whether a block can or cannot be unloaded.</p>
<p>Its conditions are:</p>
<ol>
<li><p>The block must currently be loaded.</p>
</li>
<li><p>The reader count must be zero.</p>
</li>
<li><p>If <code>block_id</code> &gt; <code>MAXIMUM_BLOCK</code>, the block is only flagged as unloadable if there exists a temporary directory to store this block. Otherwise, it returns false.</p>
</li>
</ol>
</li>
<li><p>When something needs to read or write to a specific block, a BufferHandle is provided to this fn or user. This loads/pins it, which increments the reader count. Unpinning decrements it and further triggers a check of the reader count. If it becomes zero, this block is pushed into the eviction queue. When memory pressure occurs, <code>CanUnload()</code> is run on this block. If this returns true, it may then be unloaded.</p>
</li>
</ul>
<p>A few more questions that I encountered in the process of this deconstruction:</p>
<blockquote>
<p>Is getting access to a FileBuffer the same as getting access to the block which resides in that buffer?</p>
</blockquote>
<p><code>GetFileBuffer()</code> <strong>itself does not magically give you a high-level "Block object."</strong> It gives you the underlying byte storage. The code that knows the block's structure interprets those bytes as the block's actual contents.</p>
<blockquote>
<p>Is a decrement in the reader count the sole consequence and purpose of unpinning? Same question for Pinning(is the increment the sole purpose?).</p>
</blockquote>
<p>Pinning loads the block into RAM(if not already loaded), increments the reader count, and returns a BufferHandle to the requester. Unpinning reverses all of this.</p>
]]></content:encoded></item><item><title><![CDATA[Deconstructing the DuckDB Buffer System.. Part 2]]></title><description><![CDATA[After deconstructing the bufferpool, I was off to analysing the block_manager.cpp(here's a link to it). The question at the core of this component of the…]]></description><link>https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-2</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-2</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Wed, 09 Sep 2026 15:54:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/f5932ba7-8b5e-494d-a064-07bcc3266f00.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After deconstructing the bufferpool, I was off to analysing the <code>block_manager.cpp</code>(here's a <a href="https://github.com/duckdb/duckdb/tree/v2.0-cyanoptera/src/storage/buffer">link</a> to it). The question at the core of this component of the buffer system was "How is a disk block requested?”.</p>
<p>The caller identifies the block through its <code>BlockHandle</code>; it asks the <code>BufferManager</code> to pin that block. If the block's contents are not currently in RAM, DuckDB first gets a piece of RAM for that block, and then reads the block's bytes from disk into that RAM.</p>
<p>Alongside this question, there were many others which I encountered. Here are my answers to all of them:</p>
<blockquote>
<p>What is the difference between the roles and capabilities of <code>BufferPool</code> and <code>BufferManager</code>?</p>
</blockquote>
<p><code>BufferManager</code> manages buffers; <code>BufferPool</code> provides/manages the underlying memory resource and the mechanisms needed to make that management possible.</p>
<p><strong>BufferManager = higher-level buffer operations(receptionist &amp; executioner)</strong></p>
<ul>
<li><p>allocate memory</p>
</li>
<li><p>pin blocks</p>
</li>
<li><p>unpin blocks</p>
</li>
<li><p>coordinate loading/freeing</p>
</li>
<li><p>interact with other DuckDB components</p>
</li>
</ul>
<p><strong>BufferPool = underlying memory-resource management</strong></p>
<ul>
<li><p>tracks the available memory capacity</p>
</li>
<li><p>tracks memory consumption</p>
</li>
<li><p>participates in eviction</p>
</li>
<li><p>reclaims memory when necessary</p>
</li>
<li><p>provides the memory resource that the BufferManager operates against</p>
</li>
</ul>
<blockquote>
<p>What is a <code>blockid</code>? What is a <code>BlockHandle</code>? How does it differ from a <code>BlockMemory</code>object?</p>
</blockquote>
<p>A <code>blockid</code> is simply an identifier for a block.</p>
<p>A <code>BlockHandle</code> is the C++ object that holds <em>all</em> the information DuckDB needs to ‘handle’ or manage block 42.</p>
<p>Eg:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/0e0d8aa5-ecb8-495b-86c1-0880af4fedcd.png" alt="" />

<p>A persistent block can have a <code>block_id</code> without currently having a <code>BlockHandle</code>.</p>
<p>It can exist even when the block's contents are not currently in RAM.</p>
<p>A <code>BlockMemory</code> object on other hand only stores information about the memory state(pinned, unpinned etc) and the location of the block in the RAM(i.e. which buffer). A <code>BlockMemory</code> object is a component i.e. subset of a <code>BlockHandle</code>object.</p>
<blockquote>
<p>What is a <code>BlockManager</code>?</p>
</blockquote>
<p>An instance of <code>BlockManager</code>owns and manages a collection of <code>BlockHandle</code>objects. Here, ownership of a block means “This BlockManager keeps track of this handle”, or that the block belongs to this <code>BlockManager</code>'s storage context. Multiple blocks can have the same <code>blockid</code>, but not the same manager, therefore to refer to a block one needs 1. the instance of <code>BlockManager</code> owning that block and 2. the <code>blockid</code>. Neither of them can individually address a block.</p>
<blockquote>
<p>What’s the difference between TryGetBlock() and RegisterBlock()?</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/c0300b5b-7873-41a8-b2ee-eaac80e10d3c.png" alt="" />

<p>Does <code>RegisterBlock()</code> fill every field of a <code>BlockHandle</code> once it creates it?</p>
<p>No, <code>RegisterBlock()</code> itself isn't manually filling every field of a <code>BlockHandle</code>. It constructs the <code>BlockHandle</code>, and the <code>BlockHandle</code>'s implementation determines what information it contains.</p>
<blockquote>
<p>Where does the usage of weak_ptr come in the picture of blockid, b_handle, b_manager etc if anywhere?</p>
</blockquote>
<p>The BlockManager keeps a weak_ptr reference to the blockhandles that correspond to that instance.</p>
<blockquote>
<p>What is the <code>BufferHandle</code>?</p>
</blockquote>
<p><code>BufferHandle</code> gives you temporary access to the RAM buffer that currently contains the block's data, not ownership/management of the block itself.</p>
<blockquote>
<p>What does expire and lock do or return wrt a weak ptr?</p>
</blockquote>
<p><code>lock()</code> returns nullptr if the object doesn’t exist anymore and a <code>shared_ptr</code> to the object if it does, whereas <code>expire()</code> gives boolean value for the same.</p>
]]></content:encoded></item><item><title><![CDATA[Deconstructing the DuckDB Buffer System.. Part 1]]></title><description><![CDATA[Choosing Database Internals as my niche, I chose DuckDB as the open-source project to contribute to. I began deconstructing its buffer system(here's a link…]]></description><link>https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-1</link><guid isPermaLink="true">https://theshadowbyte.hashnode.dev/deconstructing-the-duckdb-buffer-system-part-1</guid><dc:creator><![CDATA[Akshat Kumar]]></dc:creator><pubDate>Sat, 05 Sep 2026 14:18:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/1c6c14f2-f165-4bfc-9725-f724c0fc1172.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Choosing Database Internals as my niche, I chose DuckDB as the open-source project to contribute to. I began deconstructing its buffer system(here's a <a href="https://github.com/duckdb/duckdb/tree/v2.0-cyanoptera/src/storage/buffer">link</a> to it), starting with <code>bufferpool.cpp</code>.</p>
<p>Here's what I understood about the code.</p>
<p>The two core objects that are repeatedly mentioned:</p>
<ul>
<li><p><code>BufferPool</code>: Provides/manages the underlying memory resource and the mechanisms needed to let the memory receptionist (BufferManager) use it.</p>
</li>
<li><p><code>BlockMemory</code>: Knows the status of blocks with respect to the relevant aspects. Different BlockMemory objects contain metadata about different blocks of memory.</p>
</li>
</ul>
<p>Blocks are pinned when a user or a local function requests them, and are unpinned when they are no longer needed. An unpinned block gets placed in the eviction queue but not unloaded. They are evicted only under memory pressure.</p>
<p>The following is the trajectory of a freshly evicted(or dequeued) candidate:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/c29e0945-04f5-4450-b8b6-2faf40f2b59a.png" alt="" />

<p><code>Discard</code> — a newer entry exists.</p>
<p><code>Leave block</code> — the entry is current, but A is being used again.</p>
<p>Proceeding with the analysis of this file, I came across several implementation questions. This is my attempt at answering each of them:</p>
<blockquote>
<p>What are eviction queues?</p>
</blockquote>
<p>They are waiting queues for entries to be removed. Eviction is just one of the ways of deleting an object.</p>
<blockquote>
<p>What are weak and shared ptrs?</p>
</blockquote>
<p>A <code>shared_ptr</code> keeps a count of the number of owners(<em>all</em> the shared_ptrs pointing to the object) of the object to which it points. When this no. drops to 0, the object is destroyed.</p>
<p>A <code>weak_ptr</code> tracks (only) the lifetime of an object owned by some <code>shared_ptr</code> using functions like <code>lock()</code> and <code>expire()</code>.</p>
<blockquote>
<p>“BufferPool starts removing entries from these queues and tries to unload them.” What’s the different between removing and unloading?</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6a9c0a2185b7af57ae3ad6a6/59734115-7bc5-4553-a6f6-a97b998c6079.png" alt="" />

<blockquote>
<p>Why does the eviction queue have a hierarchy according to which eviction is conducted?</p>
</blockquote>
<p>There is a certain a cost to evicting entities. The eviction queue is thus divided into 3 based on how much they free at the cost of how much time is spend at the act of doing so.</p>
<blockquote>
<p>In the case of a dead node, why would removing the old copy be expensive? What does ‘this queue entry is obsolete’ mean? Which one of the two is a dead node? What are sequence numbers?</p>
</blockquote>
<p>Say a node A is evicted, and then taken out for use again, then evicted again. Thus the queue now has 2 entries of A. The older one(which is called the dead node) needs to be removed.</p>
<p>Searching for an entry mid-queue is expensive. Instead, a hash table called page directory hashes serial no.s to entries in the queue. This way, when A reappears in the eviction queue, it increments its hash value to 2. Later, when the older value reaches the tail, the sequence no.s of the dead node and the current hashed value of A are compared. If they match, the block is unloaded. If not, it is simply discarded, as the inequality indicates that the block at the tail is not its latest entry.</p>
<blockquote>
<p>What does purging a queue mean?</p>
</blockquote>
<p>Purging involves taking a batch out of the eviction queue, discarding dead nodes in bulk, and then putting it back into the queue. The newer batch becomes the newest entries; they are not removed and put back into the queue from the tail side.</p>
<blockquote>
<p>Does the system constantly check every block to see which one it can push into the eviction queue?</p>
</blockquote>
<p>No; once the reader count drops to zero, the function <code>CanUnload()</code> checks other conditions. A block is pushed to the eviction queue only when all the conditions hold.</p>
]]></content:encoded></item></channel></rss>