<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Frane Jelavic, Author at ShiftMag</title>
	<atom:link href="https://shiftmag.dev/author/frane-jelavic/feed/" rel="self" type="application/rss+xml" />
	<link>https://shiftmag.dev/author/frane-jelavic/</link>
	<description>Insightful engineering content &#38; community</description>
	<lastBuildDate>Thu, 24 Sep 2026 09:04:31 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://shiftmag.dev/wp-content/uploads/2026/09/cropped-Shift-32x32.webp</url>
	<title>Frane Jelavic, Author at ShiftMag</title>
	<link>https://shiftmag.dev/author/frane-jelavic/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>MongoDB: One write, two logs</title>
		<link>https://shiftmag.dev/mongodb-one-write-two-logs-12267/</link>
		
		<dc:creator><![CDATA[Frane Jelavic]]></dc:creator>
		<pubDate>Thu, 24 Sep 2026 09:04:30 +0000</pubDate>
				<category><![CDATA[Data]]></category>
		<category><![CDATA[database internals]]></category>
		<category><![CDATA[mongodb]]></category>
		<category><![CDATA[replication]]></category>
		<category><![CDATA[storage engines]]></category>
		<category><![CDATA[wiredtiger]]></category>
		<guid isPermaLink="false">https://shiftmag.dev/?p=12267</guid>

					<description><![CDATA[<p>At 55,000 operations per second across a 20 TB sharded cluster, you stop theorizing about databases and start reading their source code.</p>
<p>The post <a href="https://shiftmag.dev/mongodb-one-write-two-logs-12267/">MongoDB: One write, two logs</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></description>
										<content:encoded><![CDATA[<figure class="wp-block-post-featured-image"><img fetchpriority="high" decoding="async" width="1200" height="620" src="https://shiftmag.dev/wp-content/uploads/2026/09/mongodb-one-write-two-logs.png?x32039" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="" style="object-fit:cover;" srcset="https://shiftmag.dev/wp-content/uploads/2026/09/mongodb-one-write-two-logs.png 1200w, https://shiftmag.dev/wp-content/uploads/2026/09/mongodb-one-write-two-logs-300x155.png 300w, https://shiftmag.dev/wp-content/uploads/2026/09/mongodb-one-write-two-logs-766x396.png 766w, https://shiftmag.dev/wp-content/uploads/2026/09/mongodb-one-write-two-logs-1024x529.png 1024w" sizes="(max-width: 1200px) 100vw, 1200px" /></figure>


<p class="wp-block-paragraph">When I first encountered MongoDB, I tried to understand it through the database I knew best: PostgreSQL. My journey was a <em>baptism by fire</em>. I was managing a sharded cluster with 20 TB of data and as many as ~55k operations per second, including ~29k writes. The cluster had six shards, each with its own replica set, a config server replica set, and six routers.</p>



<p class="wp-block-paragraph">To operate that cluster safely, I had to stop treating MongoDB like PostgreSQL and learn how it worked internally.</p>



<p class="wp-block-paragraph">At one point, while tackling a flow control problem on the stated cluster, an AI model recommended increasing the portion of memory assigned to the WiredTiger cache. At the moment, the recommendation sounded genuine and correct, but I did not know what the model had based its conclusions on. To be completely honest, I didn’t even know how RAM is used by MongoDB. The nail in the coffin was the <a href="https://www.mongodb.com/docs/manual/core/wiredtiger/#memory-use">official docs</a> saying don’t change WiredTiger cache to RAM ratio.</p>



<p class="wp-block-paragraph">That experience clarified why I wanted to learn the internals. I want/need enough understanding to connect operational advice to a mechanism, identify the evidence it depends on, and decide. This applies whether the advice comes from AI, a colleague, documentation, or my own assumptions.</p>



<p class="wp-block-paragraph">What happens after a MongoDB client sends a write?</p>



<p class="wp-block-paragraph">How does that write pass through MongoDB and WiredTiger, and when does it become durable on disk?</p>



<p class="wp-block-paragraph">And when someone recommends changing a WiredTiger setting, what would have to be true for that recommendation to make sense?</p>



<h2 class="wp-block-heading"><span id="following-one-write">Following one write</span></h2>



<p class="wp-block-paragraph">When a client sends a write operation to a sharded cluster, the request first reaches a router called <code>mongos</code>. Routers cache data from the config server about chunk &#8211; shard placements. Using this data <code>mongos</code> determine which shard owns the relevant data and forward the operation to that shard&#8217;s primary node.</p>



<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="972" src="https://shiftmag.dev/wp-content/uploads/2026/09/shardedCluster-1024x972.png?x32039" alt="" class="wp-image-12271" srcset="https://shiftmag.dev/wp-content/uploads/2026/09/shardedCluster-1024x972.png 1024w, https://shiftmag.dev/wp-content/uploads/2026/09/shardedCluster-300x285.png 300w, https://shiftmag.dev/wp-content/uploads/2026/09/shardedCluster-767x728.png 767w, https://shiftmag.dev/wp-content/uploads/2026/09/shardedCluster.png 1200w" sizes="(max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">Sharded MongoDB cluster&nbsp;</figcaption></figure>



<p class="wp-block-paragraph">However, this post does not compare replica sets and sharded clusters. Once routing is complete, the same question applies inside every shard.</p>



<p class="wp-block-paragraph">How does the primary <code>mongod</code> process request, persist it, and replicate it to secondary nodes?</p>



<h2 class="wp-block-heading"><span id="separation-of-concern">Separation of concern</span></h2>



<p class="wp-block-paragraph">To answer my question(s), I firstly had to understand where <code>mongod</code> ends and the storage engine <code>WiredTiger</code> begins.</p>



<p class="wp-block-paragraph"><code>mongod</code> handles client commands, authorization, query execution, transaction coordination, and replication. WiredTiger is the embedded storage engine that stores collection and index data. It provides local transactions, MVCC, caching, compression, journaling, checkpoints, and crash recovery.</p>



<pre class="wp-block-code"><code>MongoDB client
      │ write operation
      ▼
mongos query router
      │ routes to the owning shard
      ▼
shard primary (mongod)
      ├── authorization and command handling
      ├── query parsing and execution
      ├── transaction and session coordination
      ├── replication and oplog coordination
      └── storage-engine API
                  │
                  ▼
             WiredTiger
                  ├── local transactions and MVCC
                  ├── internal cache
                  ├── journal
                  ├── checkpoints
                  └── collection and index data in dbPath</code></pre>



<p class="wp-block-paragraph">Ok, but how does this come into play? Boundaries are not absolute.</p>



<p class="wp-block-paragraph">The oplog belongs to MongoDB’s (logical) replication model, but WiredTiger stores it alongside other collection data.</p>



<h2 class="wp-block-heading"><span id="one-write-two-logs">One write, two logs</span></h2>



<p class="wp-block-paragraph">At this point, I had another question. If MongoDB already has a <code>journal</code>, why does it also need an oplog?</p>



<p class="wp-block-paragraph">The WiredTiger journal is the closest equivalent to PostgreSQL&#8217;s WAL (Write Ahead Log). It records the changes needed to recover <strong>one</strong> MongoDB member (<em>keep this thought</em>) after a crash. Unlike PostgreSQL&#8217;s WAL, MongoDB does not use the journal as its replication stream.</p>



<p class="wp-block-paragraph">The <code>oplog</code> has a different purpose. It is a capped collection stored in the <code>local</code> database as <code>local.oplog.rs</code>. Secondary members copy its entries and apply them to their own data.</p>



<p class="wp-block-paragraph">This raises an important consistency question. What prevents MongoDB from committing a document change without its corresponding oplog entry?</p>



<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="597" src="https://shiftmag.dev/wp-content/uploads/2026/09/twoWritePaths-v2-1024x597.png?x32039" alt="" class="wp-image-12273" srcset="https://shiftmag.dev/wp-content/uploads/2026/09/twoWritePaths-v2-1024x597.png 1024w, https://shiftmag.dev/wp-content/uploads/2026/09/twoWritePaths-v2-300x175.png 300w, https://shiftmag.dev/wp-content/uploads/2026/09/twoWritePaths-v2-768x448.png 768w, https://shiftmag.dev/wp-content/uploads/2026/09/twoWritePaths-v2.png 1200w" sizes="(max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">Client write path&nbsp;</figcaption></figure>



<h3 class="wp-block-heading"><span id="data-folder">Data folder</span></h3>



<p class="wp-block-paragraph">Let’s examine what happens on one node and then additionally complicate the story by adding secondaries.</p>



<p class="wp-block-paragraph">To understand local durability, I first looked inside the <code>dbPath</code> of a MongoDB member. A simplified listing:</p>



<pre class="wp-block-code"><code>$ ls /data/mongodb/
collection-&lt;ident>.wt
index-&lt;ident>.wt
journal/
WiredTiger
WiredTigerHS.wt
WiredTiger.lock
WiredTiger.turtle
WiredTiger.wt</code></pre>



<p class="wp-block-paragraph">The exact files and their names depend on the MongoDB version and configuration, but these are the main WiredTiger files relevant to this discussion.</p>



<p class="wp-block-paragraph">The <code>collection-&lt;ident&gt;.wt</code> and <code>index-&lt;ident&gt;.wt</code> files are WiredTiger tables that store collection records and index entries.</p>



<p class="wp-block-paragraph">MongoDB’s durable catalog maps logical objects, such as a collection namespace, to these storage-engine identifiers. WiredTiger maintains its own metadata in <code>WiredTiger.wt</code>, including its tables, their configuration, and their latest checkpoints.</p>



<p class="wp-block-paragraph">The small <code>WiredTiger</code> text file identifies the WiredTiger version used to create the database:</p>



<pre class="wp-block-code"><code>WiredTiger
WiredTiger 10.0.2: (November 30, 2021)</code></pre>



<p class="wp-block-paragraph"><code>WiredTigerHS.wt</code> is the history store. It keeps older committed versions of records needed by MVCC readers, snapshots, etc. WiredTiger removes versions once no active reader or snapshot can require them.</p>



<p class="wp-block-paragraph"><code>WiredTiger.turtle</code> contains enough metadata to locate and open the latest checkpoint of <code>WiredTiger.wt</code>. It bootstraps the metadata table during startup and recovery.</p>



<pre class="wp-block-code"><code>WiredTiger version string
WiredTiger 10.0.2: (November 30, 2021)
WiredTiger version
major=10,minor=0,patch=2
file:WiredTiger.wt
...
checkpoint_lsn=(2248,16291200)
...</code></pre>



<p class="wp-block-paragraph">The <code>checkpoint_lsn</code> identifies a position in the WiredTiger journal using a log file number and byte offset. During recovery, WiredTiger opens the last consistent checkpoint and replays later journal records.</p>



<p class="wp-block-paragraph">The <code>journal/</code> directory contains those write-ahead log files. They protect changes made after the last checkpoint from being lost during an unexpected shutdown.</p>



<p class="wp-block-paragraph">Finally, <code>WiredTiger.lock</code> is the file on which WiredTiger acquires a lock to prevent two processes from opening the same database directory simultaneously. The lock state matters, not merely the existence of the file.</p>



<h3 class="wp-block-heading"><span id="the-wiredtiger-cache">The WiredTiger cache</span></h3>



<p class="wp-block-paragraph">WiredTiger does not modify the <code>collection-*.wt</code> and <code>index-*.wt</code> files directly for every client operation. It first reads the required pages into its internal cache and applies the change there as part of a storage transaction. Modified pages become dirty until WiredTiger reconciles and writes them to disk.</p>



<p class="wp-block-paragraph">This internal cache keeps uncompressed collection data and is separate from the operating system’s filesystem cache where compressed data is stored.</p>



<p class="wp-block-paragraph">MongoDB therefore benefits from <strong>both caches</strong>, WiredTiger and the OS cache.</p>



<p class="wp-block-paragraph">Giving all available memory to the WiredTiger cache would leave too little memory for the filesystem cache and the rest of <code>mongod</code> and respecting the default, <code>max((availRAM - 1024) x 0.5, 256 MB)</code>, is strongly advised by <a href="https://www.mongodb.com/docs/manual/core/wiredtiger/#memory-use">the official docs.</a></p>



<p class="wp-block-paragraph">This is already enough to show why “increase the WiredTiger cache” is incomplete advice. A larger cache does not create memory. It transfers memory away from the filesystem cache and other allocations. Whether that trade is useful depends on the workload and the source of the observed pressure.</p>



<p class="wp-block-paragraph">When the cache approaches its limits, WiredTiger evicts pages to make room. Clean pages can be discarded and read again later. Dirty pages must first be reconciled into an on-disk representation.</p>



<p class="wp-block-paragraph">A committed change does not have to wait for its dirty page to reach the collection file. The journal supplies durability between checkpoints. This separation is the reason a write can be durable even though its final data page is still dirty in memory.</p>



<h3 class="wp-block-heading"><span id="recovering-after-a-crash">Recovering after a crash</span></h3>



<p class="wp-block-paragraph">In case of a crash, WiredTiger starts from the last complete checkpoint. The checkpoint metadata identifies a consistent view of the WiredTiger tables and the journal position associated with it.</p>



<p class="wp-block-paragraph">WiredTiger then replays the journal records created after that checkpoint. At a high level, recovery combines two inputs:</p>



<p class="wp-block-paragraph">&nbsp;last complete checkpoint + later durable journal records = recovered local state &nbsp;</p>



<h3 class="wp-block-heading"><span id="checkpoints-and-data-files">Checkpoints and data files</span></h3>



<p class="wp-block-paragraph">WiredTiger normally creates a checkpoint every (configurable default) 60 seconds. A checkpoint can take longer when there is more dirty data or the storage device is under pressure.</p>



<p class="wp-block-paragraph">During a checkpoint, WiredTiger writes a point-in-time view of dirty pages to the collection, index, history-store, and metadata files.</p>



<pre class="wp-block-code"><code>WiredTiger transaction
        │
        ├── dirty pages in cache ──checkpoint──&#x25b6; .wt data files
        │
        └── journal records ───────────────────&#x25b6; journal files

                         crash
                           │
                           ▼
       latest checkpoint + subsequent journal records
                           │
                           ▼
                    recovered state
</code></pre>



<h3 class="wp-block-heading"><span id="how-the-primary-records-an-operation">How the primary records an operation</span></h3>



<p class="wp-block-paragraph">For an incoming query, <code>mongod</code> will, among other things, determine collection and index changes and construct the oplog logical entry.</p>



<p class="wp-block-paragraph">It uses WiredTiger to start/use a transaction and either commits both parts or commits neither. This prevents a successful document change from existing on the primary without an operation (oplog entry) that secondaries can replicate.</p>



<p class="wp-block-paragraph">The oplog entry contains an operation type, namespace, timestamp, and the data required to reproduce the change. Its timestamp provides an ordering position in the replica-set history. The entry describes a logical insert, update, delete, command, or transaction operation rather than the low-level page modifications stored in the WiredTiger journal.</p>



<pre class="wp-block-code"><code>rs_a &#91;direct: primary] local> db.oplog.rs.findOne()
{
  ts: Timestamp(...),
  t: NumberLong(...),
  op: "u",
  ns: "foo.bar",
  o: { $v: 2, diff: { ... } },
  o2: { _id: 123 }
}</code></pre>



<p class="wp-block-paragraph">This is not a complete definition of how oplog and collection changes work, but the important invariant is: <strong>the primary commits the replicated data changes together with the oplog records which describe them.</strong></p>



<p class="wp-block-paragraph">Only the local commit is atomic. Sending or copying the oplog entry across the network is not part of that storage transaction.</p>



<h3 class="wp-block-heading"><span id="how-secondaries-copy-and-apply-oplog-entries">How secondaries copy and apply oplog entries</span></h3>



<p class="wp-block-paragraph">Each secondary continuously selects a sync source and streams newer oplog entries from it. The sync source is often the primary.</p>



<p class="wp-block-paragraph">The secondary writes fetched entries to its own oplog and then applies the represented operations to its local collections and indexes.</p>



<p class="wp-block-paragraph">The secondary uses its own WiredTiger instance to apply changes. They modify its cache, produce local journal records, and later become part of its checkpoints.</p>



<p class="wp-block-paragraph">This is the important distinction and the answer why we can’t just ship primaries <code>*.wt</code> binaries to the secondary.</p>



<p class="wp-block-paragraph">On two MongoDB servers, these are different files.</p>



<h3 class="wp-block-heading"><span id="oplog-versus-journal">Oplog versus journal</span></h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><tbody><tr><td><strong>Property</strong></td><td><strong>Oplog</strong></td><td><strong>Journal</strong></td></tr><tr><td>Layer</td><td>MongoDB</td><td>WiredTiger</td></tr><tr><td>Purpose</td><td>Replication</td><td>Crash recovery</td></tr><tr><td>Contents</td><td>Logical MongoDB operations</td><td>Storage-engine recovery records</td></tr><tr><td>Consumed by</td><td>Secondary nodes</td><td>WiredTiger during startup/recovery</td></tr><tr><td>Scope</td><td>ReplicaSet logical history</td><td>One server only</td></tr><tr><td>Replicated?</td><td>Yes, logically</td><td>No</td></tr></tbody></table></figure>



<h3 class="wp-block-heading"><span id="takeaways">Takeaways</span></h3>



<p class="wp-block-paragraph">This gives a practical sequence for evaluating operational changes:</p>



<ol start="1" class="wp-block-list">
<li>Identify which component owns the behavior</li>



<li>Describe the mechanism that is a cause of the problem</li>



<li>Find evidence supporting that mechanism</li>



<li>Clarify what the improvements should be</li>



<li>Measure</li>



<li>Iterate</li>
</ol>



<p class="wp-block-paragraph">The technical facts in this article support that way of reasoning:</p>



<ul class="wp-block-list">
<li>The WiredTiger journal and the oplog solve different problems. The journal recovers one member; the oplog replicates logical operations between members.</li>



<li>MongoDB commits a replicated data change and its oplog entry in the same local storage transaction on the primary.</li>



<li>A durable change can still exist as a dirty cache page because the journal protects it between checkpoints.</li>



<li>Primaries and secondaries have separate WiredTiger databases, caches, journals, and data files.</li>



<li>Memory assigned to WiredTiger is part of a larger allocation decision that includes the filesystem cache and other process memory.</li>
</ul>



<h2 class="wp-block-heading"><span id="why-i-wanted-to-understand-this">Why I wanted to understand this</span></h2>



<p class="wp-block-paragraph">Learning how WiredTiger works does not mean that I can derive every production decision from first principles. It gives me a way to examine a recommendation instead of accepting it because the source sounds confident, and we all know AI can sound like that.</p>



<p class="wp-block-paragraph">The quality of an (AI) recommendation depends on the question and the evidence supplied to it.</p>



<h2 class="wp-block-heading"><span id="further-reading">Further reading</span></h2>



<ul class="wp-block-list">
<li><a href="https://www.mongodb.com/docs/manual/core/wiredtiger/">MongoDB manual: WiredTiger storage engine</a></li>



<li><a href="https://www.mongodb.com/docs/manual/core/journaling/">MongoDB manual: Journaling</a></li>



<li><a href="https://www.mongodb.com/docs/manual/core/replica-set-oplog/">MongoDB manual: Replica set oplog</a></li>



<li><a href="https://www.mongodb.com/docs/manual/core/replica-set-sync/">MongoDB manual: Replica set synchronization</a></li>



<li><a href="https://github.com/mongodb/mongo/blob/master/src/mongo/db/storage/README.md">MongoDB source documentation: Storage engine</a></li>
</ul>



<p class="wp-block-paragraph"><strong><em>A note on scope</em></strong> <em>&#8211; This article records my current understanding of MongoDB and WiredTiger, built through hands-on operation and continued study. I have tried to keep the technical details accurate, but any errors or oversimplifications are mine. <a href="https://www.mongodb.com/docs/manual/">MongoDB official docs</a> and <a href="https://source.wiredtiger.com/develop/index.html">WiredTiger documentation</a> should always be consulted for correctness and clarity.</em></p>
<p>The post <a href="https://shiftmag.dev/mongodb-one-write-two-logs-12267/">MongoDB: One write, two logs</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to Survive Database Failover &#8211; Debezium and PostgreSQL in Production</title>
		<link>https://shiftmag.dev/how-to-survive-database-failover-debezium-and-postgresql-in-production-11676/</link>
		
		<dc:creator><![CDATA[Frane Jelavic]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 10:54:27 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Debezium]]></category>
		<category><![CDATA[Kafka]]></category>
		<category><![CDATA[Patroni]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<guid isPermaLink="false">https://shiftmag.dev/?p=11676</guid>

					<description><![CDATA[<p>Getting Debezium to stream the first PostgreSQL change into Kafka is straightforward; keeping it correct through failover is the interesting part.</p>
<p>The post <a href="https://shiftmag.dev/how-to-survive-database-failover-debezium-and-postgresql-in-production-11676/">How to Survive Database Failover &#8211; Debezium and PostgreSQL in Production</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></description>
										<content:encoded><![CDATA[<figure class="wp-block-post-featured-image"><img loading="lazy" decoding="async" width="1200" height="630" src="https://shiftmag.dev/wp-content/uploads/2026/08/Debezium-and-PostgreSQL.png?x32039" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="" style="object-fit:cover;" srcset="https://shiftmag.dev/wp-content/uploads/2026/08/Debezium-and-PostgreSQL.png 1200w, https://shiftmag.dev/wp-content/uploads/2026/08/Debezium-and-PostgreSQL-300x158.png 300w, https://shiftmag.dev/wp-content/uploads/2026/08/Debezium-and-PostgreSQL-1024x538.png 1024w, https://shiftmag.dev/wp-content/uploads/2026/08/Debezium-and-PostgreSQL-768x403.png 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>


<p class="wp-block-paragraph">This post describes a failure mode we addressed while <strong>operating Debezium with PostgreSQL 16 and Patroni 3.3.1</strong>.</p>



<p class="wp-block-paragraph">After promoting a standby, Debezium needed a logical replication slot from which it could safely continue. Creating a new slot after promotion could start beyond Debezium’s last durable offset and introduce an event gap. </p>



<p class="wp-block-paragraph">The central lesson was to treat PostgreSQL’s replication slot and Debezium’s Kafka Connect offset <strong>as</strong> <strong>parts of one distributed checkpoint</strong>.</p>



<h2 class="wp-block-heading"><span id="the-pipeline">The pipeline</span></h2>



<p class="wp-block-paragraph">I have worked with applications that use <strong>the transactional outbox pattern</strong>. A business change and the corresponding outbox record are committed in the same database transaction. Debezium reads changes to the outbox table from PostgreSQL’s write-ahead log, transforms them into the event format expected by consumers, and publishes them to Kafka.</p>



<figure class="wp-block-image"><img decoding="async" src="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/images/appFlow.png" alt="An application commits business data and an outbox record to PostgreSQL; Debezium reads the replication slot and publishes the event to Kafka"/></figure>



<p class="wp-block-paragraph">This removes the unsafe gap between committing a database transaction and separately attempting to publish an event. If Debezium or Kafka is temporarily unavailable, the committed outbox row remains available for later processing.</p>



<p class="wp-block-paragraph"><strong>The pipeline provides at-least-once delivery</strong>. A failure can occur after an event reaches Kafka but before the corresponding source offset is durably recorded. Debezium can then replay the event after restarting, so consumers must be idempotent.</p>



<h2 class="wp-block-heading"><span id="wal-refresher">WAL refresher</span></h2>



<h3 class="wp-block-heading"><span id="wal-and-lsn">WAL and LSN</span></h3>



<p class="wp-block-paragraph">PostgreSQL reads the relevant data page into memory (<code>shared buffers</code>). The page is modified in memory, and a corresponding WAL (write-ahead log) record is created. The WAL record is placed in the WAL buffers, and the corresponding page is marked as dirty with a page LSN (log sequence number). The page LSN records which WAL entry corresponds to the page’s latest changes. Before that dirty page can be written to its table or index file, the corresponding WAL must be flushed to durable storage.</p>



<figure class="wp-block-image"><img decoding="async" src="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/images/PostgresWAL.png" alt="PostgreSQL records inserts, updates, and deletes in WAL before modified pages are written during a checkpoint"/></figure>



<h3 class="wp-block-heading"><span id="wal_level-logical"><code>wal_level = logical</code></span></h3>



<p class="wp-block-paragraph">Changing&nbsp;<code>wal_level</code>&nbsp;from its default value of&nbsp;<code>replica</code>&nbsp;to&nbsp;<code>logical</code>&nbsp;is required to support logical decoding for logical replication. This change requires a full server restart. WAL includes&nbsp;<em>additional information</em>&nbsp;(catalog snapshots, changes, transaction boundaries, etc.) so that row changes can be reconstructed.</p>



<figure class="wp-block-image"><img decoding="async" src="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/images/logicalDecoding.png" alt="Logical decoding reconstructs committed transaction changes from interleaved WAL records"/></figure>



<h2 class="wp-block-heading"><span id="one-checkpoint-across-two-systems">One checkpoint across two systems</span></h2>



<p class="wp-block-paragraph">PostgreSQL and Kafka Connect each retain part of the connector’s progress:</p>



<ul class="wp-block-list">
<li>PostgreSQL’s logical replication slot retains the WAL required by the connector.</li>



<li>Kafka Connect stores Debezium’s most recently committed source offset in its offset topic.</li>
</ul>



<p class="wp-block-paragraph">Conceptually, <strong>progress moves through the system like this</strong>:</p>



<pre class="wp-block-code"><code>PostgreSQL generates WAL
      └─ the logical slot exposes changes
            └─ Debezium streams and decodes
                  ├─ advances slot's LSN → PG recycles older WAL
                  └─ commits source offset to Kafka topic
</code></pre>



<p class="wp-block-paragraph">These positions do not remain identical at every instant. PostgreSQL, Debezium, and Kafka progress independently. The important invariant is:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">After failover, PostgreSQL must retain every WAL record Debezium might request from its last durable Kafka Connect offset.</p>
</blockquote>



<p class="wp-block-paragraph">Starting from an older position may replay events. Starting from a newer position may skip them. Replay is recoverable with idempotent consumers; an LSN gap is not. The accompanying&nbsp;<a href="https://github.com/FraneJelavic/postgres-debezium-playground" target="_blank" rel="noreferrer noopener">PostgreSQL 16 and Debezium playground</a>&nbsp;can be used to inspect slots, publications, and connector offsets in a local cluster.</p>



<h2 class="wp-block-heading"><span id="the-postgresql-16-failover-problem">The PostgreSQL 16 failover problem</span></h2>



<p class="wp-block-paragraph">PostgreSQL 16 supports logical decoding and logical replication slots on a standby. However, <strong>it does not automatically synchronize the primary’s logical slot state with the standbys</strong>.</p>



<p class="wp-block-paragraph">Before failover, Debezium consumes through a logical slot on the primary while Kafka Connect stores its durable source offset:</p>



<figure class="wp-block-image"><img decoding="async" src="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/images/beforeFailover.png" alt="Before failover, Debezium reads the logical slot on the primary while Kafka Connect stores its durable LSN in the offset topic"/></figure>



<p class="wp-block-paragraph">If the standby is promoted without a usable copy of that slot, Debezium cannot simply continue from the same checkpoint. Creating a replacement slot on the new primary is not equivalent. A new slot begins from a position available at creation time and cannot be moved backward to recover WAL that is no longer retained.</p>



<figure class="wp-block-image"><img decoding="async" src="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/images/afterFailover.png" alt="After failover, a replacement slot starts beyond Kafka Connect’s durable LSN, creating a potential event gap"/></figure>



<p class="wp-block-paragraph">No events were lost in our case. We identified this as a failure mode that had to be eliminated before the standby could safely accept Debezium after promotion.</p>



<h2 class="wp-block-heading"><span id="patroni-permanent-logical-slots">Patroni permanent logical slots</span></h2>



<p class="wp-block-paragraph"><a href="https://github.com/cyberdem0n" target="_blank" rel="noreferrer noopener">Alexander Kukushkin (the Patroni guy)</a>&nbsp;explains <strong>how Patroni solved this problem with permanent replication slots</strong> in a&nbsp;<a href="https://www.youtube.com/watch?v=SllJsbPVaow" target="_blank" rel="noreferrer noopener">podcast</a>&nbsp;with&nbsp;<a href="https://github.com/NikolayS" target="_blank" rel="noreferrer noopener">Nikolay Samokhvalov</a>&nbsp;.</p>



<p class="wp-block-paragraph">As described in Patroni’s official documentation for&nbsp;<a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html#dynamic-configuration-settings" target="_blank" rel="noreferrer noopener">permanent replication slots</a>, logical slots are copied from the primary to standby nodes and their positions are advanced periodically. Patroni’s&nbsp;<a href="https://patroni.readthedocs.io/en/latest/modules/patroni.postgresql.slots.html" target="_blank" rel="noreferrer noopener">slot synchronization implementation</a>&nbsp;creates missing logical slots on replicas by copying them from the primary and advances existing slots when their confirmed position falls behind.</p>



<p class="wp-block-paragraph">Permanent logical slots require&nbsp;<code>postgresql.use_slots</code>&nbsp;to be enabled. Patroni also enforces the&nbsp;<code>hot_standby_feedback</code>&nbsp;setting on nodes that host permanent logical slots so the primary retains catalog rows needed for logical decoding.</p>



<p class="wp-block-paragraph"><strong>Potential problems:</strong></p>



<ul class="wp-block-list">
<li>The requested WAL segment&nbsp;<code>pg_wal/XXX</code>&nbsp;has been removed.</li>



<li>The physical slot is behind the logical slot (unlikely to happen).
<ul class="wp-block-list">
<li>The physical slot did not reach the&nbsp;<code>catalog_xmin</code>&nbsp;transaction of the logical slot on the old primary. Patroni logs a warning that it might be unsafe to use.</li>
</ul>
</li>
</ul>



<h3 class="wp-block-heading"><span id="zeno%e2%80%99s-paradox"><a href="https://en.wikipedia.org/wiki/Zeno%27s_paradoxes" target="_blank" rel="noreferrer noopener">Zeno’s paradox</a></span></h3>



<p class="wp-block-paragraph">Enabling permanent slots on an already busy cluster was more difficult than enabling them on a new cluster.</p>



<p class="wp-block-paragraph">While a standby was restarting and catching up, the logical slot on the primary continued to advance. PostgreSQL could remove catalog tuples required for logical decoding before the copied slot became usable on the standby. The standby then invalidated (<code>wal_status = lost</code>) the slot because it conflicted with recovery:</p>



<pre class="wp-block-code"><code>This slot has been invalidated because it was conflicting with recovery.
</code></pre>



<p class="wp-block-paragraph">This was not simply a matter of retaining more WAL. Logical decoding also depends on catalog visibility represented by&nbsp;<code>catalog_xmin</code>. Increasing&nbsp;<code>wal_keep_size</code>&nbsp;alone could not restore catalog rows that recovery and vacuum had already made unavailable. Increasing&nbsp;<code>max_standby_streaming_delay</code>&nbsp;also did not help while the standby still had to be rebooted.</p>



<p class="wp-block-paragraph"><strong>The system was effectively chasing a moving checkpoint</strong>: by the time the standby came back and attempted to use the copied state, the required catalog horizon had already moved.</p>



<h2 class="wp-block-heading"><span id="how-we-approached-the-transition">How we approached the transition</span></h2>



<p class="wp-block-paragraph">Instead of treating the operation as a reusable sequence of commands, <strong>we treated it as a checkpoint-alignment problem</strong>.</p>



<p class="wp-block-paragraph">In our Patroni 3.3.1 rollout, allowing Patroni to introduce missing permanent logical slots required restarting the busy standbys so that the copied slot state could take effect. Those restarts created the race described above: by the time a standby returned, the required catalog horizon could already have moved.</p>



<p class="wp-block-paragraph">The turning point was to <strong>create the corresponding logical slot directly on each PostgreSQL 16 standby before enabling Patroni’s permanent-slot configuration</strong>. PostgreSQL could create those slots without restarting the standbys. We aligned each slot only to a checkpoint that we had verified as safe, then reloaded the Patroni configuration. Because the slots already existed, Patroni adopted them and continued advancing their positions instead of entering the missing-slot copy path.</p>



<p class="wp-block-paragraph">This is intentionally an analysis rather than a reusable runbook. The safe checkpoint depends on the primary, standby replay position, retained WAL, and Debezium’s durable offset; advancing a slot too far can create the event gap this process is meant to prevent.</p>



<p class="wp-block-paragraph">The property we validated was:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Every candidate primary retained a usable logical slot covering every WAL record Debezium might request after promotion.</p>
</blockquote>



<p class="wp-block-paragraph">Subsequent production failovers completed without detected LSN gaps or skipped events.</p>



<h2 class="wp-block-heading"><span id="what-postgresql-17-changes">What PostgreSQL 17 changes</span></h2>



<p class="wp-block-paragraph">PostgreSQL 17 introduced synchronized failover slots. A logical slot marked for failover can be synchronized from the primary to a standby using PostgreSQL’s native slot-synchronization facilities. The official&nbsp;<a href="https://www.postgresql.org/docs/17/logical-replication-failover.html" target="_blank" rel="noreferrer noopener">PostgreSQL 17 logical replication failover documentation</a>&nbsp;describes the configuration and readiness checks.</p>



<p class="wp-block-paragraph">This reduces the need for PostgreSQL 16-era slot-copying approaches, but it does not remove the operational responsibility. Synchronization is asynchronous, and failover readiness must still be verified before promotion.</p>



<p class="wp-block-paragraph">For PostgreSQL 17 and newer deployments, use the native failover-slot design and its readiness checks instead of copying this PostgreSQL 16 and Patroni 3.3.1 approach.</p>



<h2 class="wp-block-heading"><span id="takeaways">Takeaways</span></h2>



<ol class="wp-block-list">
<li>Treat PostgreSQL’s replication slot and Debezium’s offset as one distributed checkpoint.</li>



<li>Prefer replay with idempotent consumers over any possibility of an LSN gap.</li>



<li>Make logical-slot continuity part of the database failover design.</li>



<li>Distinguish a healthy connector from a proven gap-free recovery.</li>



<li>Test promotion with real writes and checkpoint comparisons, not only process-health checks.</li>



<li>Scope operational guidance to the PostgreSQL and Patroni versions for which it was validated.</li>
</ol>



<h3 class="wp-block-heading"><span id="this-blog-post-was-originally-published-on-frane-jelavic%e2%80%99s-blog"><em>This blog post was originally published on <a href="https://franejelavic.github.io/writing/debezium-postgresql-production-lessons/" target="_blank" rel="noreferrer noopener">Frane Jelavic’s blog</a>.</em></span></h3>
<p>The post <a href="https://shiftmag.dev/how-to-survive-database-failover-debezium-and-postgresql-in-production-11676/">How to Survive Database Failover &#8211; Debezium and PostgreSQL in Production</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Database migration: Developers&#8217; open-heart surgery</title>
		<link>https://shiftmag.dev/database-migration-developers-open-heart-surgery-1926/</link>
		
		<dc:creator><![CDATA[Frane Jelavic]]></dc:creator>
		<pubDate>Mon, 06 Nov 2023 09:09:00 +0000</pubDate>
				<category><![CDATA[Backend]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Database migration]]></category>
		<category><![CDATA[Live Database Migration experience]]></category>
		<guid isPermaLink="false">https://shiftmag.dev/?p=1926</guid>

					<description><![CDATA[<p>Live database migrations are developers' heart surgeries – and ours sure felt like it. Even now we have a long-lasting relationship with the patient and are keeping an eye on the surgery aftermath.</p>
<p>The post <a href="https://shiftmag.dev/database-migration-developers-open-heart-surgery-1926/">Database migration: Developers&#8217; open-heart surgery</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></description>
										<content:encoded><![CDATA[<figure class="wp-block-post-featured-image"><img loading="lazy" decoding="async" width="1200" height="630" src="https://shiftmag.dev/wp-content/uploads/2023/11/live-Database-migration.png?x32039" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="" style="object-fit:cover;" srcset="https://shiftmag.dev/wp-content/uploads/2023/11/live-Database-migration.png 1200w, https://shiftmag.dev/wp-content/uploads/2023/11/live-Database-migration-300x158.png 300w, https://shiftmag.dev/wp-content/uploads/2023/11/live-Database-migration-1024x538.png 1024w, https://shiftmag.dev/wp-content/uploads/2023/11/live-Database-migration-768x403.png 768w" sizes="auto, (max-width: 1200px) 100vw, 1200px" /></figure>


<p class="wp-block-paragraph">The task at hand was to <strong>migrate from the AWS relational PostgreSQL database to our on-prem self-hosted solution</strong>. We assembled a mission-based team comprising a Developer, DevOps, and two DBAs.</p>



<p class="wp-block-paragraph">The main driver of this migration was the <strong>steadily increasing monthly bill we got from AWS</strong>. If you don&#8217;t read all the fine print when navigating through AWS UI, you could be unpleasantly surprised by the amount of dollars you end up spending.</p>



<p class="wp-block-paragraph">But, we chose AWS for a valid reason. We had a POC (Proof Of Concept) and wanted to quickly gather customer feedback.</p>



<p class="wp-block-paragraph">We chose PostgreSQL at that time and opted for an &#8220;enhanced&#8221; Aurora version. Although we had an on-premise (in-house) solution, it was never battle-tested.</p>



<p class="wp-block-paragraph">From there, our journey&nbsp;began…</p>



<h2 class="wp-block-heading"><span id="choose-your-technologies-carefully"><strong>Choose your technologies carefully</strong></span></h2>



<p class="wp-block-paragraph">Our cluster was&nbsp;<a href="https://aws.amazon.com/rds/aurora/features/">AWS Aurora PostgreSQL RDS</a>&nbsp;with one writer and 4 standby servers. The servers were db.r5.8xlarge instances with 32 vCPU and 256 GB RAM with Aurora storage.</p>



<p class="wp-block-paragraph">The storage solution provides almost instant (approx. 20 ms) replication, with 4 out of 6 commit quorum.</p>



<p class="wp-block-paragraph">All servers use the same underlying storage, so commits from writers are propagated instantly to standbys, and asynchronous replication updates only the data in memory (shared buffers).</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="476" src="https://shiftmag.dev/wp-content/uploads/2023/10/image-1-1024x476.png?x32039" alt="" class="wp-image-1927" srcset="https://shiftmag.dev/wp-content/uploads/2023/10/image-1-1024x476.png 1024w, https://shiftmag.dev/wp-content/uploads/2023/10/image-1-300x139.png 300w, https://shiftmag.dev/wp-content/uploads/2023/10/image-1-768x357.png 768w, https://shiftmag.dev/wp-content/uploads/2023/10/image-1.png 1252w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="560" src="https://shiftmag.dev/wp-content/uploads/2023/10/image-2-1024x560.png?x32039" alt="" class="wp-image-1928" srcset="https://shiftmag.dev/wp-content/uploads/2023/10/image-2-1024x560.png 1024w, https://shiftmag.dev/wp-content/uploads/2023/10/image-2-300x164.png 300w, https://shiftmag.dev/wp-content/uploads/2023/10/image-2-768x420.png 768w, https://shiftmag.dev/wp-content/uploads/2023/10/image-2.png 1087w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph"><em>Reference from&nbsp;<a href="https://www.youtube.com/watch?v=--jED3W8Qy4&amp;t=1772s">AWS PostgreSQL In-Depth Training</a>.</em></p>



<p class="wp-block-paragraph">So we had help from the beginning&#8230;</p>



<p class="wp-block-paragraph">Replication was instant, so we hooked up the whole UI on our standby servers and got an immediate real-time response. Our solution was built on the assumption that changes from the writer server will be, nearly instantly, available to our standbys.</p>



<p class="wp-block-paragraph">The writer server was offloaded by efficiently using the standbys. We used Enhanced monitoring, set up Cloud Watch dashboards, analyzed and tracked every query on Performance Insight, and created alarms for every anomaly spotted in the setup.</p>



<p class="wp-block-paragraph">The level of observability and monitoring let us diagnose issues very quickly and correctly.&nbsp;Little did we know that <strong>that behavior differed widely from Vanilla Postgres</strong>. But we would find out soon enough.</p>



<h2 class="wp-block-heading"><span id="think-about-your-data-early"><strong>Think about your data early</strong></span></h2>



<p class="wp-block-paragraph">Our database DDL&nbsp;was structured&nbsp;to support the product launch and was never meant to be a long-term solution.</p>



<p class="wp-block-paragraph">Data&nbsp;was added&nbsp;but never deleted. Tables were not partitioned, and indexes have metastasized with various workloads. <strong>This resulted in tables with terabytes and indexes with hundreds of gigabytes of size.</strong></p>



<p class="wp-block-paragraph">We had to restructure. Keep the traffic data together and distinguish it from read-only and configuration data.</p>



<p class="wp-block-paragraph">Archiving was a very long and IO-intensive feature to develop, and it clashed with the migration efforts. During the product lifecycle, we had stale and test data we wanted to get rid of. <strong>We started data cleanup and removed gigabytes of data.</strong></p>



<p class="wp-block-paragraph">Again conflicting with the migration process.</p>



<h2 class="wp-block-heading"><span id="aws-charges-everything"><strong>AWS charges everything</strong></span></h2>



<p class="wp-block-paragraph">We didn&#8217;t start with a huge cluster, but with time and feature development, user adoption, and traffic increase, we had proportional demands on our underlying DB hardware.</p>



<p class="wp-block-paragraph">Fast forward to being approx. 4-5 years on the market, in production,<strong> we had huge I/O usages of our standbys. </strong>UI was sucking in two standbys, and their usage was through the roof in peak traffic hours, while the other standbys were battling the remaining read queries.</p>



<p class="wp-block-paragraph">We also hooked up some analytic reports on one of the servers to provide better statistics for our users.</p>



<p class="wp-block-paragraph">The monthly bill was rising steadily. Although we neglected costs apart from instance uptime, every I/O request had its price and so did our setup.</p>



<h2 class="wp-block-heading"><span id="on-premise-setup"><strong>On-premise setup</strong></span></h2>



<p class="wp-block-paragraph">Although our cluster was not tested with the same traffic AWS was handling at the moment, <strong>our setup was powerful.</strong></p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="462" src="https://shiftmag.dev/wp-content/uploads/2023/10/image-3-1024x462.png?x32039" alt="" class="wp-image-1929" srcset="https://shiftmag.dev/wp-content/uploads/2023/10/image-3-1024x462.png 1024w, https://shiftmag.dev/wp-content/uploads/2023/10/image-3-300x135.png 300w, https://shiftmag.dev/wp-content/uploads/2023/10/image-3-768x347.png 768w, https://shiftmag.dev/wp-content/uploads/2023/10/image-3.png 1522w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">We had complete data center redundancy, meaning if a whole DC crashes, we could failover to the other one. Each DC had servers spread across three availability zones, so redundancy on the network level was also covered. Cluster orchestration with Patroni allowed for smooth failovers/switchovers and performing upgrades without downtime or maintenance window scheduling.</p>



<p class="wp-block-paragraph">Hardware-wise, we had Intel&#8217;s Platinum CPUs, a few generations better than we had provisioned on AWS. Storage was NVMe SSDs, so IO bottlenecks we had on AWS should be history.</p>



<h2 class="wp-block-heading"><span id="use-your-standbys-with-caution"><strong>Use your standbys with caution</strong><strong></strong></span></h2>



<p class="wp-block-paragraph">PostgreSQL allows you to use standby servers in a variety of ways:</p>



<ul class="wp-block-list">
<li><em>High availability</em>&nbsp;where the standbys&#8217; primary purpose is not to fall back from the writer and to replay WAL logs</li>



<li><em>Offloading big queries,</em>&nbsp;AKA poor men&#8217;s data warehousing</li>



<li><em>Horizontal scaling</em>, performing select queries to off-load the writer database</li>
</ul>



<p class="wp-block-paragraph">Every setup comes with its pros and cons, leading to&nbsp;streaming replication conflicts. We needed to choose whether we preferred replication changes or serving the current queries running on the standby(s).</p>



<p class="wp-block-paragraph"><strong>While the abilities of configuration are broad, they are not bulletproof.</strong> The development mindset had to change, since we were now dealing with streaming replication instead of the one provided by Aurora (which was almost completely synchronous without latency increase).</p>



<p class="wp-block-paragraph">Applications using standbys need to cope with&nbsp;<strong>eventual consistency.&nbsp;</strong>UI architecture for displaying real-time data from standbys in the new setup became an oxymoron from a logical perspective.</p>



<h2 class="wp-block-heading"><span id="limitations-of-logical-replication"><strong>Limitations of logical replication</strong></span></h2>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/current/logical-replication.html" target="_blank" rel="noreferrer noopener">Logical replication</a>&nbsp;was used to transfer data from the current cluster to AWS. We had previous experience migrating data but on a completely different DDL layout (remember the portion of the text &#8220;<em>Think about your data early</em>&#8220;).</p>



<p class="wp-block-paragraph">The replication process has two phases.&nbsp;<strong>Initial data sync</strong>, where all the data currently in the table/schema is migrated, and the <strong>&#8220;catch-up&#8221; phase</strong> where the cluster syncs the subsequent changes from WAL logs.</p>



<p class="wp-block-paragraph">Since logical replication does not copy DDL changes to the target server, all changes were frozen for other developers working on the product.</p>



<p class="wp-block-paragraph">Our efforts were primarily aimed at <strong>reducing the cost</strong>, but we’ve managed to give ourselves some extra work in order to<strong> bring more value and earnings to the product.</strong></p>



<p class="wp-block-paragraph">The migration started by introducing replication slots on the source (AWS cluster) and subscribers on the target (on-premise). The first strategy, empirically, was to define one replication slot per database schema, as we did in our previous migration(s). Smaller schemas, up to 50 GB in size, were synced extremely fast and we experienced no issues.</p>



<h2 class="wp-block-heading"><span id="incidents-from-database-degradations">Incidents from database degradations</span></h2>



<p class="wp-block-paragraph">By the time we got to our main schema counting several TB of size,&nbsp;<strong>the first incident </strong><strong>happened</strong>. During the initial sync phase, WAL logs with subsequent changes are kept on the primary server. Given the way PostgreSQL provides&nbsp;<a href="https://www.postgresql.org/docs/current/transaction-iso.html">transaction isolation</a>, initial sync is thought of as a long-running transaction on the primary instance.</p>



<p class="wp-block-paragraph">The&nbsp;<a href="https://postgrespro.com/blog/pgsql/5967899">snapshot</a>&nbsp;xmin&nbsp;value is held and with it the&nbsp;event horizon. When this value is locked all queries &#8220;see&#8221; the DB state from the moment the transaction with xmin value started. In layman&#8217;s terms, every query has to start the execution from the snapshot of the DB that it was in at the moment of the ongoing transaction and go through changes (tuple versions) applied in the meantime.</p>



<p class="wp-block-paragraph"><strong>This results in prolonged query cost, inaccurate database statistics, and the most problematic one bloating of the database</strong> since&nbsp;<a href="https://www.postgresql.org/docs/current/sql-vacuum.html" target="_blank" rel="noreferrer noopener">the vacuum</a>&nbsp;is postponed. Approximately 5-6 hours in the replication we experienced complete degradation. IOPS got exhausted and we hit AWS limits, for our database instance level. <strong>We had to stop the replication, lose all the data currently migrated, and start over.</strong></p>



<p class="wp-block-paragraph">On the second attempt, armed with the experience we had from the first execution, we first divided our replication slots inside the schemas. Tables small in size were merged into a single replication slot, and every other table got its dedicated replication slot. Additional monitoring and alarms were set up to check the bloat and queries that were first impacted in the previous execution. Bigger tables were stripped from their indexes, and we decided to add them after the complete sync was done. <strong>The only index kept was the primary key one</strong>, since logical replication needs at least one unique constraint upon applying subsequent changes from WAL logs.</p>



<h2 class="wp-block-heading"><span id="the-migration-was-still-a-struggle"><strong>The migration was still a struggle.</strong> </span></h2>



<p class="wp-block-paragraph">The whole team was checking dozens of metrics and also browsing through our UI solution examining delays upon usage. We had to stop the migration and <strong>start over more than once</strong>, but this time it affected only a single, problematic table. Once we got to the biggest table inside our database, we needed to<strong> remove the primary key as well and recreate it once the initial sync was replicated.</strong></p>



<p class="wp-block-paragraph">We managed to copy all of the data to our on-premise cluster and were in the sync phase. Features such as data removal were halted until we fully migrated and we were now maintaining two separate clusters.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="538" src="https://shiftmag.dev/wp-content/uploads/2023/11/Database-migration1-1-1024x538.png?x32039" alt="" class="wp-image-1948" srcset="https://shiftmag.dev/wp-content/uploads/2023/11/Database-migration1-1-1024x538.png 1024w, https://shiftmag.dev/wp-content/uploads/2023/11/Database-migration1-1-300x158.png 300w, https://shiftmag.dev/wp-content/uploads/2023/11/Database-migration1-1-768x403.png 768w, https://shiftmag.dev/wp-content/uploads/2023/11/Database-migration1-1.png 1200w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<h2 class="wp-block-heading"><span id="load-tests-with-business-specific-scenarios"><strong>Load tests with business-specific scenarios</strong></span></h2>



<p class="wp-block-paragraph">Data was replicated, clusters were up and running, DDL was synced, applications were working without issues, and the overhead of logically replicating changes didn&#8217;t affect the AWS cluster. Now what?</p>



<p class="wp-block-paragraph">We needed to put our HW to the test, and we needed a fallback if we ran into problems. </p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>This is the moment where we regretted not putting that extra time into having a load test.</strong> Not any kind of load test &#8211; the one that specifically mimics our production behavior, a user on our solution, and the ability to multiply it by a custom number.</p>
</blockquote>



<p class="wp-block-paragraph">Luckily, the majority of our load was SELECTs. We used our load balancers to include standby servers from the on-prem solution together with production ones. This allowed us to easily fall back, and kick out of the balancer, in case of degradation.</p>



<p class="wp-block-paragraph">The major downside of our approach was that <strong>every latency introduced in the system was immediately affecting the client.</strong> We have repeatable tests that monitor the solution and, in 99% of the cases, spot the degradation before the user does, but there is still that 1%. Apart from <strong>&#8220;testing in production&#8221;</strong>, another major downside is that there were too many unknowns inside the equation to have exact conclusions. There is always a probability something else is affecting the results when you are part of a live system.</p>



<p class="wp-block-paragraph">During these tests, we saw that our backups were disturbing our workloads and that we would need to introduce <strong>a separate server for full and incremental backups</strong>, something we didn&#8217;t even think about while we were on AWS.</p>



<p class="wp-block-paragraph">Amzon&#8217;s <a href="https://aws.amazon.com/rds/performance-insights/" target="_blank" rel="noreferrer noopener">Performance Insight</a>&nbsp;is worth every penny. Only when we stopped using it and lost the ability to check the lifecycle of every executed query and pinpoint the exact wait event causing the delay did we realize how powerful it was. </p>



<p class="wp-block-paragraph">We had to implement our solution, and we did, but again, we learned that <strong>we took some things for granted when using AWS.</strong></p>



<h2 class="wp-block-heading"><span id="the-patient-recovered-in-no-time"><strong>The </strong>patient recovered in no time</span></h2>



<p class="wp-block-paragraph">The final maintenance was scheduled for the complete migration of all services to the new on-prem solution. Here our preparation was on point. We&#8217;ve completely&nbsp;<strong>automated&nbsp;the process</strong>, we just needed to execute a few CLI commands to gracefully migrate the whole solution. Given the usage of our product, the only window we had, with minimal traffic and client impact, was from 0:00-3:00 UTC. Since we are in CET this meant a long night for us.</p>



<p class="wp-block-paragraph">We were struggling not to fall asleep before the maintenance started and would probably struggle to sleep afterward. <strong>The migration went surgically precise, and our patient recovered in no time.</strong> The window was scheduled for a full three hours, and we managed to redeploy, clean up error logs, and validate in a little more than an hour.</p>



<p class="wp-block-paragraph">Although falling asleep was hard because of the adrenaline spikes, the sense of accomplishment helped us rest our tired eyes.</p>



<h2 class="wp-block-heading"><span id="life-after-migration"><strong>Life after migration</strong></span></h2>



<p class="wp-block-paragraph">Looking back at the journey of the migration, a pragmatic observer could spot the good and the bad of each setup we had. <strong>Product maturity, access to powerful on-prem hardware, gathered know-how inside the company and cost reduction made the decision of the migration a no-brainer.</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">However, there is a lot of work now on our hands that we just &#8220;threw money at&#8221; when using the cloud. Maybe the best thing to understand as an organization and as a developer is to recognize when you are ready to make this step.</p>
</blockquote>



<p class="wp-block-paragraph">Identify a step in your product lifecycle where such moves have to be taken. With increased usage of your solution, you should think about scalability and cost-efficiency not only on service level or feature bottlenecks but the ecosystem as a whole.</p>



<p class="wp-block-paragraph">Definitely choose your technologies wisely in the beginning. When you introduce new tech (platform change, &nbsp;database, framework, or even a library), build up expertise, check the knowledge inside the company, <strong>understand and take responsibility for bugs in the tech now being your bugs.</strong></p>



<p class="wp-block-paragraph">Don&#8217;t forget to think about scale.<strong> Premature optimization is the root of all evil, </strong>they say, but nevertheless, your design has to have a vision.</p>



<p class="wp-block-paragraph">These decisions are not easy, but neither is our job. We are fortunate to grow as individuals, learning by doing and owning our decisions, making us better developers but also human beings.</p>
<p>The post <a href="https://shiftmag.dev/database-migration-developers-open-heart-surgery-1926/">Database migration: Developers&#8217; open-heart surgery</a> appeared first on <a href="https://shiftmag.dev">ShiftMag</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: shiftmag.dev @ 2026-10-25 01:40:43 by W3 Total Cache
-->