<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charan Reddy Rachumalla</title>
    <description>The latest articles on DEV Community by Charan Reddy Rachumalla (@charanrachumalla).</description>
    <link>https://dev.to/charanrachumalla</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198917%2Febde7395-fd17-4c7a-8851-193909658f20.png</url>
      <title>DEV Community: Charan Reddy Rachumalla</title>
      <link>https://dev.to/charanrachumalla</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/charanrachumalla"/>
    <language>en</language>
    <item>
      <title>Kafka Consumers on EKS: Designing for a Platform That Restarts Your Consumer Group</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:56:22 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/kafka-consumers-on-eks-designing-for-a-platform-that-restarts-your-consumer-group-hf2</link>
      <guid>https://dev.to/charanrachumalla/kafka-consumers-on-eks-designing-for-a-platform-that-restarts-your-consumer-group-hf2</guid>
      <description>&lt;p&gt;&lt;em&gt;The failure modes that actually page you — and the boring fixes that stop them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Kafka consumer is a stateful client built on a membership protocol. It joins a group, accepts a partition assignment, processes records in order, and proves it is still alive on a fixed cadence of heartbeats and poll calls. Every guarantee the group offers — ordered processing, shared load, exactly one owner per partition — rests on that membership staying stable while the work flows through it.&lt;/p&gt;

&lt;p&gt;Kubernetes starts from the opposite assumption. A pod is disposable. The platform restarts pods, throttles CPU, rolls deployments, and reclaims nodes on a schedule the consumer group never agreed to, and each of those routine events reaches the group protocol as a member leaving, arriving, or going silent. Most consumer incidents on EKS are not Kafka bugs. They are the collision between a stateful, membership-based client and an orchestrator doing exactly what it was designed to do.&lt;/p&gt;

&lt;p&gt;In this post, I start with the baseline configuration that prevents half of these problems outright, then walk through the eight failure modes that account for nearly everything else — symptom, mechanism, and fix, which is usually a config line or a lifecycle hook rather than a redesign. I close with the design principle that ties the eight together and the debug routine that names a failure mode in minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qcb4doxpeds9qg7bhbi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qcb4doxpeds9qg7bhbi.png" alt=" " width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The core tension
&lt;/h2&gt;

&lt;p&gt;A standalone consumer fails when its code fails or when its broker fails. A consumer on EKS carries a third failure source that never sleeps: the platform itself. Rescheduling a pod is normal platform behavior. Enforcing a CPU limit is normal platform behavior. Terminating a container during a rollout is normal platform behavior. The group protocol holds no category for these events except membership change, so it reacts to all of them with the tools it has — rebalances, session expirations, revoked partitions — whether the event was an accident or a planned deploy.&lt;/p&gt;

&lt;p&gt;That framing changes how the whole problem gets solved. A restart stops being an exception to recover from and becomes a permanent condition to design around: stable identities, generous timing, and a shutdown path the group can see coming. The eight modes below are what happens when a design skips that framing and assumes the laptop's stability on the platform's terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the baseline
&lt;/h2&gt;

&lt;p&gt;Half of these issues never happen with a sane baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;session.timeout.ms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10000&lt;/span&gt;
&lt;span class="py"&gt;heartbeat.interval.ms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3000&lt;/span&gt;
&lt;span class="py"&gt;max.poll.interval.ms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;300000&lt;/span&gt;
&lt;span class="py"&gt;max.poll.records&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;500&lt;/span&gt;
&lt;span class="py"&gt;partition.assignment.strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;CooperativeStickyAssignor&lt;/span&gt;
&lt;span class="py"&gt;enable.auto.commit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;auto.offset.reset&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;earliest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions in that block do most of the work. Manual commits put offset progress under the application's control, so a restart replays records instead of silently skipping or duplicating them. Cooperative-sticky assignment keeps partitions with their current owners through a rebalance wherever it can, instead of revoking everything and starting the assignment from zero. A poll interval with real headroom gives slow processing room to finish before the group concludes the member has died. Everything below is what still goes wrong on top of that baseline — and what to do when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eight failure modes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pod restart and CrashLoop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; The pod keeps restarting; the consumer leaves and rejoins the group constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; An application error or an OOMKill; a long startup; uncaught exceptions inside the poll loop. The poll loop is the consumer's heartbeat in the broad sense — the thread that polls is the thread that processes, so an exception that escapes it takes the whole member down, and the group sees a departure to rebalance around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Fix resource limits, handle exceptions in the loop, and commit sync or async with idempotent processing, so a restart replays safely instead of corrupting progress. Idempotency is the load-bearing half of that fix: a consumer that can replay a batch without harm treats a restart as a routine event, while a consumer that cannot turns every CrashLoop into a data problem on top of an availability problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Session timeout — heartbeats missed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; Logs report "member has failed"; frequent rebalances; consumption stalls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; GC pauses or CPU throttling starve the background heartbeat thread. The consumer is alive — it just cannot prove it in time. The heartbeat runs on its own thread precisely so that processing cannot block it. That separation holds only while the scheduler gives the thread its share of CPU; a throttled container breaks it quietly, and the first sign is the group expelling a member that was never down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Raise &lt;code&gt;session.timeout.ms&lt;/code&gt;, fix CPU and memory limits so the heartbeat thread gets scheduled, and keep &lt;code&gt;poll()&lt;/code&gt; being called regularly. Raising the timeout buys tolerance; fixing the limits removes the cause. The timeout increase without the limits change leaves a consumer that fails more slowly instead of one that stops failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. max.poll.interval.ms exceeded
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; "Consumer is not polling" — the member is kicked out mid-processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Processing per batch takes longer than the poll interval allows, usually because of slow database or API calls inside the loop. The poll interval is the group's second liveness check: it measures not whether the member exists but whether the member is still making progress through its assignment, and slow downstream calls stretch a single batch past the limit without anything being wrong with the consumer itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Raise &lt;code&gt;max.poll.interval.ms&lt;/code&gt;, shrink batches with &lt;code&gt;max.poll.records&lt;/code&gt;, or hand work to worker threads with pause/resume so the poll loop keeps moving. The three options trade against each other — a longer interval tolerates slower batches, smaller batches bound the work per cycle, and worker threads decouple processing from polling at the cost of more moving parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Rebalance storms
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; The group spends more time reassigning partitions than processing records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Pods scaling up and down, rolling restarts, short timeouts, and an eager assignor that revokes everything on every change. A rebalance is not free: assignments pause while partitions move, in-flight work gets discarded or replayed, and a group that rebalances in a loop spends its capacity on coordination instead of consumption while lag climbs behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use cooperative-sticky assignment, tune the HPA carefully, and stabilize the pod lifecycle — no random restarts. Cooperative assignment revokes only the partitions that actually need to move, and sticky ownership keeps the rest where they are, so a single pod event stops being a group-wide interruption. Autoscaling against a consumer group needs that softness underneath it, because every scale event is a membership event.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Offset commit failures
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; &lt;code&gt;CommitFailedException&lt;/code&gt; in the logs; records reprocessed after a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; A stale generation after a rebalance, or the coordinator moved or is unreachable. A commit is a claim about progress made under a specific generation of the group, and the coordinator rejects claims from a generation that no longer exists — the consumer processed the records, and the group has already moved on without recording that fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Commit only inside the poll loop, handle &lt;code&gt;CommitFailedException&lt;/code&gt; and retry, and use &lt;code&gt;commitAsync&lt;/code&gt; with callbacks carefully. Committing inside the loop keeps the commit attached to the generation that did the work. The reprocessing that follows a rejected commit is the group working as designed; the goal is making replays safe and rare.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Resource limits — CPU and memory
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; High CPU throttling, long GC pauses, OOMKilled pods, missed heartbeats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Limits set too low for the batch sizes in use; JVM heap not matched to container memory. This mode sits underneath several of the others — throttling starves the heartbeat thread of mode 2, stretches the batch processing of mode 3, and turns the memory pressure of mode 1 into an OOMKill. A consumer's batch size is also a memory decision, since every record in the batch lives in the heap until the batch finishes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Set requests and limits thoughtfully, tune the JVM (&lt;code&gt;-Xmx&lt;/code&gt; against container memory, not host memory), right-size message batches, and watch it all with Prometheus and Grafana. The host-memory mistake is the subtle one: a JVM sized for the node it lands on will be killed by a container limit it never knew existed, and the pod restart that follows presents as a consumer problem with a platform cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. DNS and bootstrap servers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; &lt;code&gt;UnknownHostException&lt;/code&gt;; the consumer cannot connect at startup; intermittent failures after that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Wrong bootstrap addresses, CoreDNS issues, or private DNS without the VPC setup to back it. This is the one mode on the list that fails before the group protocol is even involved — the consumer cannot reach a broker to join anything — and the intermittent version is worse than the hard failure, because it presents as flakiness instead of a name-resolution problem with a fixed address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use the correct bootstrap DNS names, verify CoreDNS in the EKS cluster, and check VPC DNS settings and private hosted zones. Every layer of that chain is platform surface, and all of them have to agree before the consumer's own configuration gets a chance to matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Ungraceful shutdown
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; Every deploy triggers a full rebalance; partitions get reassigned the slow way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Kubernetes kills the pod with no chance to leave the group or commit offsets. From the group's side, a terminated member and a crashed member look identical — both simply stop responding — so the group waits out the session timeout and then reassigns everything, on every single deploy, even though the shutdown was planned in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Add a PreStop hook that calls &lt;code&gt;close()&lt;/code&gt;, commit offsets before shutdown, and set &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; generously enough for the close to finish. A clean &lt;code&gt;close()&lt;/code&gt; is the consumer announcing its departure: the group rebalances immediately and narrowly, offsets land before the pod dies, and a deploy stops looking like an outage. The grace period is the budget that makes the announcement possible, and it has to be long enough for the close to actually complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for the platform
&lt;/h2&gt;

&lt;p&gt;None of these eight modes is exotic, and that is the point. Read together, they reduce to a short list of design commitments. Timing gets budgeted as a first-class resource, sized for a platform that throttles and pauses things rather than a quiet laptop. Shutdown becomes a feature with its own code path instead of an abrupt disappearance the group has to time out. Identity stays as stable as the platform allows, so sticky assignments have something to stick to. And processing is written to survive replay, because replays are the group's recovery mechanism and no configuration makes them fully disappear.&lt;/p&gt;

&lt;p&gt;The tradeoffs stay honest. Generous timeouts tolerate platform noise and also delay detection of a genuinely dead member. Cooperative-sticky assignment calms rebalances and still cannot hold a partition whose owner is gone. Bigger resource limits end throttling and cost money on every pod, every hour. The design point is choosing these tensions deliberately, not eliminating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug fast
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kafka-consumers
kubectl logs &amp;lt;pod&amp;gt;
kubectl get events
kafka-consumer-groups.sh &lt;span class="nt"&gt;--describe&lt;/span&gt; &lt;span class="nt"&gt;--group&lt;/span&gt; &amp;lt;group&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lag plus member state tells you which issue you are in. Name the issue first; the fix follows from the name. The pod list and events stream show the platform's half of the story — restarts, kills, throttling — and the group description shows the protocol's half: who owns what, how far behind each partition sits, and whether membership is stable or churning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in practice
&lt;/h2&gt;

&lt;p&gt;With the baseline in place and the eight modes handled, the operational picture changes in specific ways. Deploys stop triggering full rebalances, because the shutdown path announces the departure and the group reassigns narrowly. Restarts replay records safely instead of corrupting progress, because commits are manual and processing is idempotent. Timing stops being the hidden cause behind pages that present as broker problems. And diagnosis gets faster: lag and member state from &lt;code&gt;kafka-consumer-groups.sh --describe&lt;/code&gt; identify which of the eight modes is active in minutes, and the fix follows from the name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On EKS, the platform is part of the consumer's failure model: restarts, throttling, and shutdowns are normal events, not exceptions.&lt;/li&gt;
&lt;li&gt;Most consumer pages trace back to timing — heartbeats, poll intervals, and shutdown grace — not to broker problems.&lt;/li&gt;
&lt;li&gt;Cooperative-sticky assignment and manual commits remove the two loudest sources of churn before tuning even starts.&lt;/li&gt;
&lt;li&gt;A graceful shutdown path is a feature: PreStop hook, a final commit, and a grace period long enough to use them.&lt;/li&gt;
&lt;li&gt;Debug by symptom against the eight modes above; lag and member state from &lt;code&gt;kafka-consumer-groups.sh --describe&lt;/code&gt; identify the mode in minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The same failure-model thinking extends to every stateful client the platform hosts. Any process that joins a group, holds an assignment, and proves liveness on a cadence will meet its own version of these eight modes, and the fixes generalize with it: budget the timing, announce the shutdown, keep the identity stable, and make the work replayable. The consumer is simply where the collision shows up first, because the group protocol writes its assumptions down in configuration — and configuration can be reviewed line by line.&lt;/p&gt;

&lt;p&gt;The platform is part of the failure model. Design for it there, in the open, and the pages stop.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>dataengineering</category>
      <category>kubernetes</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Tiered Storage in Apache Kafka — Hot Data on Disk, Old Data in S3 (and the Same Trick That Cuts a Databricks Bill)</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:32:23 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/tiered-storage-in-apache-kafka-hot-data-on-disk-old-data-in-s3-and-the-same-trick-that-cuts-a-4i61</link>
      <guid>https://dev.to/charanrachumalla/tiered-storage-in-apache-kafka-hot-data-on-disk-old-data-in-s3-and-the-same-trick-that-cuts-a-4i61</guid>
      <description>&lt;p&gt;Every Kafka cluster I've worked on had the same problem. Not throughput. Disk.&lt;/p&gt;

&lt;p&gt;The business wants a year of history kept. The brokers can hold maybe a week. So retention gets cut, data gets deleted, and three months later somebody asks for it.&lt;/p&gt;

&lt;p&gt;Tiered storage fixed this in open-source Kafka, and the idea is honestly simple.&lt;/p&gt;

&lt;p&gt;Recent data stays on the broker's local disk. That's the hot data, the stuff consumers are actually reading all day. When a segment is done and gets old, Kafka ships it to S3 and removes the local copy.&lt;/p&gt;

&lt;p&gt;Nobody notices. Same topic, same offsets. A consumer can still read six-month-old data — the broker just pulls it back from S3 when asked. It's slower, sure. Old data always is. But it works, and we didn't have to build some separate archiving pipeline on the side to make it happen.&lt;/p&gt;

&lt;p&gt;On EKS this setup is almost unfair. Tiny fast disks on the brokers, S3 doing the heavy lifting behind them. Retention stops being a math problem you solve with a spreadsheet and a prayer.&lt;/p&gt;

&lt;p&gt;On-prem works too. The storage layer is pluggable, so MinIO, Ceph, whatever S3-compatible box is already sitting in the data center — it all works the same way.&lt;/p&gt;

&lt;p&gt;And the same thinking saves money outside Kafka. Databricks is where I've seen storage bills quietly explode. Delta keeps old versions of files around for time travel, old partitions sit on full-price storage, and nobody ever goes back to clean it up.&lt;/p&gt;

&lt;p&gt;The workaround is the same idea. Leave the recent data in Delta where it's fast. Take the old partitions, write them out once as plain Parquet into an archive bucket, and let an S3 lifecycle rule push them down to Glacier pricing. Drop them from the table. If someone actually needs last year's data — and that happens maybe twice a year — read the Parquet directly. Ten minutes of work beats paying hot-storage prices forever.&lt;/p&gt;

&lt;p&gt;One honest warning. Tiered storage is not a backup. Delete the topic and the S3 data goes with it. It solves cost. It doesn't solve accidents.&lt;/p&gt;

&lt;p&gt;Hot data where it's read. Cold data where it's cheap. That's the whole trick.&lt;/p&gt;




&lt;h2&gt;
  
  
  The system design
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl655jxfir50a6zvimuad.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl655jxfir50a6zvimuad.png" alt="Tiered storage design" width="800" height="1040"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write path.&lt;/strong&gt; Producers write to the leaders as always. Nothing about the hot path changes — records land in the active log segment on local disk and are acknowledged per your normal &lt;code&gt;acks&lt;/code&gt; and replication settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offload path.&lt;/strong&gt; When a segment closes and ages past the &lt;em&gt;local&lt;/em&gt; retention window, the broker uploads it to remote storage through the Remote Storage Manager (RSM), records where it went in the Remote Log Metadata Manager (RLMM — an internal Kafka topic in the production setup), and only then deletes the local copy. Uploads are chunked (4 MiB here) so a multi-GB segment is many small, resumable S3 writes, not one giant one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read path.&lt;/strong&gt; A fetch for a recent offset is served from local disk, exactly as before. A fetch for an old offset is resolved through the RLMM to the segment's S3 location; the broker streams the needed chunks back (through a local chunk cache) and serves them as a normal fetch response. The consumer cannot tell the difference — only the latency changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two retention knobs are the whole design:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;local.retention.ms&lt;/code&gt; / &lt;code&gt;local.retention.bytes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;How long data stays on broker disk — the hot window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;retention.ms&lt;/code&gt; / &lt;code&gt;retention.bytes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Total history kept, local + remote together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On EKS&lt;/strong&gt; the brokers run as a StatefulSet with small gp3 volumes, credentials come from IRSA (no keys in the pod), and the S3 plugin arrives via an init container. &lt;strong&gt;On-prem&lt;/strong&gt;, point the same RSM at MinIO or Ceph — the design doesn't change, only the endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  The configs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Broker config — &lt;code&gt;server.properties&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Apache Kafka ships the tiered-storage &lt;em&gt;framework&lt;/em&gt; (KIP-405) but no S3 implementation, so the Remote Storage Manager is Aiven's open-source plugin — the standard choice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# Turn the tiered-storage system on for this broker
&lt;/span&gt;&lt;span class="py"&gt;remote.log.storage.system.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Remote Log Metadata Manager: topic-based (production choice)
&lt;/span&gt;&lt;span class="py"&gt;remote.log.metadata.manager.class.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.kafka.server.log.remote.metadata.storage.TopicBasedRemoteLogMetadataManager&lt;/span&gt;
&lt;span class="py"&gt;remote.log.metadata.manager.impl.prefix&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rlmm.config.&lt;/span&gt;
&lt;span class="py"&gt;rlmm.config.remote.log.metadata.topic.replication.factor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;rlmm.config.remote.log.metadata.topic.num.partitions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50&lt;/span&gt;

&lt;span class="c"&gt;# Remote Storage Manager: Aiven plugin, S3 backend
&lt;/span&gt;&lt;span class="py"&gt;remote.storage.manager.class.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.aiven.kafka.tieredstorage.RemoteStorageManager&lt;/span&gt;
&lt;span class="py"&gt;remote.storage.manager.impl.prefix&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rsm.config.&lt;/span&gt;
&lt;span class="py"&gt;rsm.config.storage.backend.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.aiven.kafka.tieredstorage.storage.s3.S3StorageBackend&lt;/span&gt;
&lt;span class="py"&gt;rsm.config.storage.s3.bucket.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;my-kafka-tiered-storage&lt;/span&gt;
&lt;span class="py"&gt;rsm.config.storage.s3.region&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;span class="c"&gt;# Credentials come from the environment (on EKS: the pod's IRSA role).
# Never put keys in this file.
&lt;/span&gt;
&lt;span class="c"&gt;# 4 MiB chunks keep S3 requests sane on big segments
&lt;/span&gt;&lt;span class="py"&gt;rsm.config.chunk.size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4194304&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Per-topic opt-in — &lt;code&gt;topic-config.sh&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kafka-configs.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; kafka-0.kafka-headless:9092,kafka-1.kafka-headless:9092,kafka-2.kafka-headless:9092 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--entity-type&lt;/span&gt; topics &lt;span class="nt"&gt;--entity-name&lt;/span&gt; events &lt;span class="nt"&gt;--alter&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-config&lt;/span&gt; remote.storage.enable&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,&lt;span class="se"&gt;\&lt;/span&gt;
local.retention.ms&lt;span class="o"&gt;=&lt;/span&gt;86400000,&lt;span class="se"&gt;\&lt;/span&gt;
retention.ms&lt;span class="o"&gt;=&lt;/span&gt;15552000000
&lt;span class="c"&gt;# local.retention.ms = 1 day on disk (hot window)&lt;/span&gt;
&lt;span class="c"&gt;# retention.ms       = 180 days total history&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The pod spec — &lt;code&gt;kafka-on-eks.yaml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The number to look at: &lt;strong&gt;50 Gi of disk&lt;/strong&gt;. Without tiered storage this same topic needed terabytes per broker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# IRSA: this role carries the S3 permissions (see iam-policy.json).&lt;/span&gt;
    &lt;span class="na"&gt;eks.amazonaws.com/role-arn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/kafka-tiered-storage&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka-config&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server.properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;process.roles=broker,controller&lt;/span&gt;
    &lt;span class="s"&gt;node.id=0&lt;/span&gt;
    &lt;span class="s"&gt;controller.quorum.voters=0@kafka-0.kafka-headless.kafka.svc:9093,1@kafka-1.kafka-headless.kafka.svc:9093,2@kafka-2.kafka-headless.kafka.svc:9093&lt;/span&gt;
    &lt;span class="s"&gt;listeners=PLAINTEXT://:9092,CONTROLLER://:9093&lt;/span&gt;
    &lt;span class="s"&gt;advertised.listeners=PLAINTEXT://kafka-0.kafka-headless.kafka.svc:9092&lt;/span&gt;
    &lt;span class="s"&gt;log.dirs=/var/lib/kafka/data&lt;/span&gt;
    &lt;span class="s"&gt;num.partitions=12&lt;/span&gt;
    &lt;span class="s"&gt;remote.log.storage.system.enable=true&lt;/span&gt;
    &lt;span class="s"&gt;remote.log.metadata.manager.class.name=org.apache.kafka.server.log.remote.metadata.storage.TopicBasedRemoteLogMetadataManager&lt;/span&gt;
    &lt;span class="s"&gt;remote.storage.manager.class.name=io.aiven.kafka.tieredstorage.RemoteStorageManager&lt;/span&gt;
    &lt;span class="s"&gt;remote.storage.manager.impl.prefix=rsm.config.&lt;/span&gt;
    &lt;span class="s"&gt;rsm.config.storage.backend.class=io.aiven.kafka.tieredstorage.storage.s3.S3StorageBackend&lt;/span&gt;
    &lt;span class="s"&gt;rsm.config.storage.s3.bucket.name=my-kafka-tiered-storage&lt;/span&gt;
    &lt;span class="s"&gt;rsm.config.storage.s3.region=us-east-1&lt;/span&gt;
    &lt;span class="s"&gt;rsm.config.chunk.size=4194304&lt;/span&gt;
&lt;span class="s"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StatefulSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka-headless&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;kafka&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;kafka&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
      &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Kafka ships no S3 RSM — fetch Aiven's open-source plugin jars.&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fetch-tiered-storage-plugin&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curlimages/curl:8.10.1&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cd&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/plugin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-sSLO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;https://github.com/Aiven-Open/tiered-storage-for-apache-kafka/releases/download/v0.10.0/tiered-storage-for-apache-kafka-0.10.0.tgz&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;xzf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tiered-storage-for-apache-kafka-0.10.0.tgz&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--strip-components=1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;plugin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/plugin&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/kafka:3.9.0&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9092&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;client&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9093&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;controller&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/kafka/data&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/opt/kafka/config/server.properties&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;server.properties&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;plugin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/opt/kafka/libs/tiered-storage&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;4Gi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8Gi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;kafka-config&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plugin&lt;/span&gt;
          &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;volumeClaimTemplates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gp3&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50Gi&lt;/span&gt;   &lt;span class="c1"&gt;# plenty, when disk only holds the hot window&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The only S3 permissions the brokers need — &lt;code&gt;iam-policy.json&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ListTieredStorageBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-kafka-tiered-storage"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ReadWriteSegments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-kafka-tiered-storage/*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Databricks half: storage workaround + EKS compute split
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Storage.&lt;/strong&gt; The archive job below runs weekly: old Delta partitions are written once as plain Parquet into an archive bucket, deleted from the table, and VACUUMed so the space is genuinely released. The bucket's lifecycle rule then does the pricing work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# databricks_archive_job.py (essentials)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="n"&gt;HOT_WINDOW_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
&lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod.events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;ARCHIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://my-archive-bucket/archive/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DATE_COL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date_sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;HOT_WINDOW_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATE_COL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Land the copy BEFORE touching the table
&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overwrite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATE_COL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARCHIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; WHERE &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DATE_COL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &amp;lt; date_sub(current_date(), &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOT_WINDOW_DAYS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VACUUM &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; RETAIN 168 HOURS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 7 days of time-travel grace
&lt;/span&gt;
&lt;span class="c1"&gt;# Reading it back, the rare time anyone asks:
#   spark.read.parquet("s3://my-archive-bucket/archive/events")
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;s&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;-lifecycle-archive.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"archive-to-glacier"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"archive/"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Transitions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"StorageClass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GLACIER_IR"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"StorageClass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEEP_ARCHIVE"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Compute.&lt;/strong&gt; The archive reads, backfills, and compaction don't need Databricks at all. You can't run Databricks on EKS — its compute is Databricks-managed — but you don't need to: Delta Lake is open source, and the same tables and Parquet archives on S3 are readable and writable by plain Apache Spark running as EKS pods, at raw EC2 prices, with &lt;strong&gt;zero DBUs&lt;/strong&gt;. Keep Databricks for the interactive work where its speed earns the markup; let cheap pods do the scheduled heavy lifting against the cold tiers, scaling to zero in between.&lt;/p&gt;




&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the storage tiers actually cost (AWS list prices, us-east-1):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;$ / GB-month&lt;/th&gt;
&lt;th&gt;20 TB of history&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 Standard (and broker-disk equivalent thinking)&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;td&gt;~$460 / month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Glacier Instant Retrieval&lt;/td&gt;
&lt;td&gt;$0.004&lt;/td&gt;
&lt;td&gt;~$80 / month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Glacier Deep Archive&lt;/td&gt;
&lt;td&gt;$0.00099&lt;/td&gt;
&lt;td&gt;~$20 / month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Worked example — 20 TB total history, 1-day hot window: ~1 TB stays hot (~$23), 19 TB sits in Glacier Instant Retrieval (~$76). &lt;strong&gt;≈ $99/month instead of ≈ $460 — about 78% off the storage line&lt;/strong&gt;, before counting the broker disks you no longer buy (50 Gi gp3 per broker above, versus terabytes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Databricks actually charges:&lt;/strong&gt; every workload pays twice — the cloud VM cost &lt;em&gt;plus&lt;/em&gt; a per-DBU charge (Flexera's 2026 Databricks pricing guide: Jobs compute $0.15/DBU Premium, serverless jobs $0.35/DBU, all-purpose compute $0.40/DBU). All-in, Databricks typically lands at roughly &lt;strong&gt;1.5–2.5× the cost of the raw compute&lt;/strong&gt; it runs on (Reintech, Apache Spark vs Databricks 2026 comparison). Moving a scheduled batch job to Spark-on-EKS removes the entire DBU half of that bill for that job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the split is legal, technically:&lt;/strong&gt; Delta Lake is an open-source Linux Foundation project — "not locked to Databricks and can be used anywhere" (Databricks' own Delta Lake overview). External engines — Apache Spark batch and streaming, Flink, DuckDB — can now create, read, and write even Unity Catalog–managed Delta tables with commits coordinated by Unity Catalog (Databricks Community technical blog, 2026).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest limits:&lt;/strong&gt; open-source Delta lacks Databricks-only optimizations (Photon, predictive I/O); Glacier Deep Archive objects must be restored in S3 before they can be read, which is why the lifecycle stops at Instant Retrieval first; self-managed Spark on EKS trades engineer time for the DBU savings; and tiered storage — Kafka or Delta — is a cost tool, not a backup.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: Flexera, "Databricks pricing guide (2026)"; Reintech, "Apache Spark vs Databricks 2026"; Dremio, "Keep Databricks for ETL. Cut Analytics Costs"; Databricks Community, "Performing DML Operations on Unity Catalog Managed Tables from External Engines"; Delta Lake documentation (docs.delta.io); AWS S3 pricing (list, us-east-1). Config files: companion bundle &lt;code&gt;kafka-tiered-storage/&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>dataengineering</category>
      <category>databricks</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>State, Not Events: Keeping a Kafka Topic Forever With Log Compaction</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:29:33 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/state-not-events-keeping-a-kafka-topic-forever-with-log-compaction-2ll4</link>
      <guid>https://dev.to/charanrachumalla/state-not-events-keeping-a-kafka-topic-forever-with-log-compaction-2ll4</guid>
      <description>&lt;p&gt;Two different systems. The same two lines of Kafka topic configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;retention.ms&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;-1&lt;/span&gt;
&lt;span class="py"&gt;log.cleanup.policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;compact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most Kafka topics are streams of events with a shelf life. Data lands, consumers read it, and after a few days or weeks the retention window deletes it. These two settings build the opposite thing. Infinite retention says records never expire by time. Log compaction says the broker keeps only the latest record for each key. Together they turn a topic into a permanent table: one current record per key, kept forever, replayable from the beginning at any moment.&lt;/p&gt;

&lt;p&gt;I have used this combination in two production systems that look nothing alike. The pattern behind both is the same, and it is the subject of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the configuration actually does
&lt;/h2&gt;

&lt;p&gt;With log.cleanup.policy=compact, the log cleaner works through the topic's older segments and drops any record whose key has a newer record later in the log. The newest value for every key always survives. With retention.ms=-1, no time-based deletion ever removes what compaction keeps. The result is a topic whose size tracks the number of distinct keys, not the volume of writes. A million updates to one key still leave one record.&lt;/p&gt;

&lt;p&gt;That gives the topic a property normal topics do not have: a consumer that reads it from the beginning reconstructs the complete current state. Not the history of how the state evolved — the state itself, exactly as it stands now. Any service can rebuild its view of the world by replaying the topic, and a brand-new consumer catches up to the same picture as everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project one: connector configs as a compacted topic
&lt;/h2&gt;

&lt;p&gt;The first system is a self-service tool for Kafka connectors. It is a Flask application. Users sign in with SSO and see cards for the connector types they can create — Salesforce, Google, MirrorMaker 2, and other Camel sources and sinks. Picking a card opens a form with the fields that connector needs. The backend is an open-source Kafka Connect distributed cluster running on EKS, loaded with the Camel connector JARs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F151qi0hnddxeuqusz0a8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F151qi0hnddxeuqusz0a8.png" alt=" " width="800" height="512"&gt;&lt;/a&gt;&lt;br&gt;
When a user submits the form, the tool produces one record to a Kafka topic. The key is the connector name. The value is the connector config. That topic runs with infinite retention and compaction.&lt;/p&gt;

&lt;p&gt;This makes the topic the registry of every connector in the system. A connector's config can never age out and disappear, because retention is infinite. An updated config does not pile duplicates on top of the old one, because compaction keeps exactly one live record per connector name — the latest write wins. The full set of connector definitions lives in one durable place, in the same system that runs them, with no separate database to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project two: user data for log enrichment
&lt;/h2&gt;

&lt;p&gt;The second system starts with an identity tool that emits user data logs — records for new users and updated records for existing users. Every value carries one unique user with all of their details. This data has to be kept forever, and there must never be duplicates for the same user.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqje35hgl1cfqlcuv78bt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqje35hgl1cfqlcuv78bt.png" alt=" " width="800" height="512"&gt;&lt;/a&gt;&lt;br&gt;
Same configuration. The key is the user. The value is the full user record. Infinite retention keeps every user's current record permanently. Compaction guarantees that no matter how many updates a user accumulates, exactly one record per user survives — the current one.&lt;/p&gt;

&lt;p&gt;This topic feeds a bigger pipeline: a log aggregation system that processes server logs from across the platform. As server logs stream through, the pipeline enriches them with user data, joining each log against the latest user record from the topic. Enrichment is only trustworthy if the reference data is complete and current, and that is exactly what the compacted topic provides. It is the full user base, deduplicated by construction, and replaying it from the beginning rebuilds that user base exactly as it stands today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule behind both
&lt;/h2&gt;

&lt;p&gt;Configs and user profiles share one trait: they describe state, not history. The useful question they answer is "what does this look like right now." Nobody needs the fourteen previous versions of a connector config or a user's profile. They need the current one, guaranteed present, guaranteed unique.&lt;/p&gt;

&lt;p&gt;That is the decision test I apply. Data that describes events — things that happened, in order, where the history itself is the value — belongs in a normal topic with time-based retention. Data that describes current state belongs in a compacted topic with infinite retention. Events get retention windows. State gets compaction.&lt;/p&gt;

&lt;p&gt;Two operational notes complete the picture. Compaction is a background process, so a superseded record can sit in the log for a while before the cleaner removes it; the guarantee is about what survives, not about instant cleanup. And a compacted topic still grows with the number of keys, so it fits state with a bounded key space — connector names, user IDs — rather than unbounded event streams.&lt;/p&gt;

&lt;p&gt;Used this way, Kafka stops being only a pipe between systems. A compacted, infinitely retained topic is a durable, replayable store of current state that any consumer can rebuild from at any time. Two lines of configuration, and the topic never forgets what things look like right now.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>dataengineering</category>
      <category>streaming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Every Step Runs Inside poll(): The Kafka Consumer Flow, End to End</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:43:33 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/every-step-runs-inside-poll-the-kafka-consumer-flow-end-to-end-2kin</link>
      <guid>https://dev.to/charanrachumalla/every-step-runs-inside-poll-the-kafka-consumer-flow-end-to-end-2kin</guid>
      <description>&lt;p&gt;Every Kafka consumer application calls &lt;code&gt;poll()&lt;/code&gt;. Very few developers can list what happens inside it.&lt;/p&gt;

&lt;p&gt;That gap shows up at the worst time: a consumer that hangs, a group that rebalances in a loop, a commit that keeps failing. Each of those failures lives in a specific step of the flow below. The flow is seven steps. The first is setup, the next four are a handshake, and the last two are the steady state the consumer spends its life in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8qe94i9vcasi4c6lace.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8qe94i9vcasi4c6lace.png" alt=" " width="800" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create consumer and subscribe
&lt;/h2&gt;

&lt;p&gt;Application code builds a &lt;code&gt;KafkaConsumer&lt;/code&gt; with &lt;code&gt;bootstrap.servers&lt;/code&gt; and a &lt;code&gt;group.id&lt;/code&gt;, then calls &lt;code&gt;subscribe()&lt;/code&gt; with the topic list.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subscribe()&lt;/code&gt; only registers interest. Nothing is fetched, nothing is assigned, no broker knows the consumer exists yet. Every step that follows is driven by &lt;code&gt;poll()&lt;/code&gt; — it is the engine of the entire client, and the handshake below unfolds across its first calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: FindCoordinator
&lt;/h2&gt;

&lt;p&gt;The consumer asks any bootstrap broker a simple question: which broker coordinates this group.&lt;/p&gt;

&lt;p&gt;The answer is the broker that leads the &lt;code&gt;__consumer_offsets&lt;/code&gt; partition assigned to the group. In this walkthrough that is Broker 3, coordinating the group &lt;code&gt;payments-group&lt;/code&gt;. From this point, all group traffic — joins, heartbeats, offset commits — goes to Broker 3. None of it goes to the brokers holding the actual data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: JoinGroup
&lt;/h2&gt;

&lt;p&gt;The consumer sends a JoinGroup request carrying its protocol preferences and timeouts:&lt;/p&gt;

&lt;p&gt;group.id=payments-group&lt;br&gt;
session.timeout.ms=45000&lt;br&gt;
rebalance.timeout.ms=300000&lt;/p&gt;

&lt;h1&gt;
  
  
  assignment protocols offered: range, cooperative-sticky
&lt;/h1&gt;

&lt;p&gt;The coordinator starts a rebalance and elects a group leader from the members. It issues a generation number — generation 17 in this walkthrough — plus a member ID for each consumer. The generation matters later: it is how the coordinator detects members acting on a stale view of the group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: SyncGroup
&lt;/h2&gt;

&lt;p&gt;The elected leader computes the partition assignment for every member and sends it back through the coordinator. SyncGroup returns this consumer's share: partitions &lt;code&gt;payments-0&lt;/code&gt; and &lt;code&gt;payments-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Only now does the consumer know what it owns. Everything before this step was negotiation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: OffsetFetch
&lt;/h2&gt;

&lt;p&gt;The consumer asks the coordinator — not the partition leaders — where the group left off. The committed offsets come back: &lt;code&gt;payments-0&lt;/code&gt; → 100, &lt;code&gt;payments-1&lt;/code&gt; → 250.&lt;/p&gt;

&lt;p&gt;Position starts from the committed offset. If a partition has no committed offset at all, &lt;code&gt;auto.offset.reset&lt;/code&gt; decides where to start. That one configuration quietly determines whether a brand-new group reads a topic from the beginning or only sees new records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Fetch
&lt;/h2&gt;

&lt;p&gt;Fetch requests go to each partition's leader broker — this is the data path, separate from the coordinator path. &lt;code&gt;payments-0&lt;/code&gt; is fetched from Broker 1, &lt;code&gt;payments-1&lt;/code&gt; from Broker 2.&lt;/p&gt;

&lt;p&gt;Each fetch starts at the consumer's position, and record batches stream back per &lt;code&gt;poll()&lt;/code&gt;. Fetch is a long poll: the broker holds the request, waiting up to &lt;code&gt;fetch.max.wait.ms&lt;/code&gt; to accumulate at least &lt;code&gt;fetch.min.bytes&lt;/code&gt; of data. An idle topic does not mean a stuck consumer. It usually means the broker is patiently waiting to send a batch worth sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Process and commit
&lt;/h2&gt;

&lt;p&gt;Application code processes the records, then commits. The commit carries the &lt;em&gt;next&lt;/em&gt; offset: after processing through offset 109 on &lt;code&gt;payments-0&lt;/code&gt;, the committed offset is 110. The coordinator writes it into &lt;code&gt;__consumer_offsets&lt;/code&gt;, and that write is the group's durable progress.&lt;/p&gt;

&lt;p&gt;A commit can fail on a stale generation. That failure means a rebalance happened mid-processing — the correct response is to rejoin and retry, not to hammer the commit. After a successful commit, the flow loops back to Fetch. Steps 6 and 7 repeat for the life of the consumer. The handshake in steps 2 through 5 only reruns when a rebalance forces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;The Java client splits this flow across a few key classes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;KafkaConsumer&lt;/strong&gt; — the API application code touches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ConsumerCoordinator&lt;/strong&gt; — group management: FindCoordinator, JoinGroup, SyncGroup, OffsetFetch, OffsetCommit, and heartbeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetcher&lt;/strong&gt; — records from the partition leaders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SubscriptionState&lt;/strong&gt; — the current assignment and position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NetworkClient&lt;/strong&gt; — everything on the wire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split explains the two network paths. Group management runs through the coordinator, Broker 3. The data path runs to the leaders, Broker 1 and Broker 2. Committed progress at the end of this walkthrough: &lt;code&gt;payments-0&lt;/code&gt; → 110, &lt;code&gt;payments-1&lt;/code&gt; → 260, stored in &lt;code&gt;__consumer_offsets&lt;/code&gt; where the next generation of the group will find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule of thumb
&lt;/h2&gt;

&lt;p&gt;When a consumer hangs, the useful move is naming the step that broke: the handshake (steps 2–5), the fetch (step 6), or the commit (step 7).&lt;/p&gt;

&lt;p&gt;It is almost never the &lt;code&gt;process()&lt;/code&gt; code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subscribe()&lt;/code&gt; registers interest and nothing more. &lt;code&gt;poll()&lt;/code&gt; drives every step that follows.&lt;/li&gt;
&lt;li&gt;Group traffic and data traffic take different paths: coordination to one broker, fetches to the partition leaders.&lt;/li&gt;
&lt;li&gt;A rebalance is the handshake rerunning. Generation numbers are how stale members get caught.&lt;/li&gt;
&lt;li&gt;A commit stores the next offset to read, and committed offsets in &lt;code&gt;__consumer_offsets&lt;/code&gt; are the group's only durable memory.&lt;/li&gt;
&lt;li&gt;Consumer problems debug fastest by step: handshake, fetch, or commit.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>distributedsystems</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Kafka MCP Server: How a Copilot Agent Safely Operates a Real Kafka Cluster</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:36:20 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/kafka-mcp-server-how-a-copilot-agent-safely-operates-a-real-kafka-cluster-58df</link>
      <guid>https://dev.to/charanrachumalla/kafka-mcp-server-how-a-copilot-agent-safely-operates-a-real-kafka-cluster-58df</guid>
      <description>&lt;p&gt;I built an MCP server for Kafka. It is a backend that connects to Kafka brokers and exposes Kafka operations as tools, defined through the Model Context Protocol, so a Copilot agent can call them. An operator asks a plain-English operational question — a consumer group falling behind, a topic that needs a closer look — and the agent answers from the live cluster instead of from memory.&lt;/p&gt;

&lt;p&gt;The interesting part was never the model. The interesting part was everything around it: the tools, the permissions, the rules. This is the design, layer by layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: You and the copilot
&lt;/h2&gt;

&lt;p&gt;The interaction starts in plain English. The operator states the problem in ordinary language. The agent interprets the intent behind the ask, then picks the right tool from the set it has been given.&lt;/p&gt;

&lt;p&gt;That last clause carries the whole design. The agent chooses from a fixed toolset. It does not get a shell, a Kafka CLI, or a blank connection string. Its world is exactly the tools on the server, nothing wider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: The MCP server
&lt;/h2&gt;

&lt;p&gt;The server defines each operation as a tool with a typed schema. Inputs are precise: a topic name is a string with constraints, a group ID is a group ID. There is no free-form parameter for the agent to improvise into.&lt;/p&gt;

&lt;p&gt;Two tools show the pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Describe topic&lt;/strong&gt; returns partitions, configs, ISR, and offsets for a topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer lag&lt;/strong&gt; returns group lag read from live metadata — committed offsets compared against the latest offsets, per partition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool is a narrow, named operation against the cluster. The server, not the agent, owns the broker connection. That connection authenticates with SASL or OAuth and runs with least-privilege permissions: the credentials can do what the tools need and nothing beyond that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: The Kafka cluster
&lt;/h2&gt;

&lt;p&gt;On the other side sits the cluster itself — brokers, topics and partitions, consumer groups with their membership, offsets, and lag, and the health signals: under-replicated partitions, offline partitions, alerts.&lt;/p&gt;

&lt;p&gt;The cluster is the live source of truth. Metadata is fetched fresh on every tool call. The agent never answers from a cached snapshot or from patterns in its training data. A lag number in the response is a lag number the cluster reported a second ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design rule 1: Tools are the contract
&lt;/h2&gt;

&lt;p&gt;A vague schema produces improvised inputs, and improvised inputs against a production cluster are a liability. Typed tools keep the agent honest. The contract is written once, by a person, in the schema — and every call the agent makes is forced through it.&lt;/p&gt;

&lt;p&gt;This is the same lesson APIs taught a decade ago, applied to a new kind of caller. The caller is probabilistic. The contract must not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design rule 2: Live state, not guesses
&lt;/h2&gt;

&lt;p&gt;An LLM can produce a fluent explanation of consumer lag with no cluster access at all. It will sound right and it may be wrong. The fix is retrieval from the system itself: answers grounded in metadata the cluster just returned.&lt;/p&gt;

&lt;p&gt;Every operational claim the agent makes traces back to a tool result. Strip the tool results away and the agent has nothing to say about this cluster — which is exactly the correct behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design rule 3: Destructive means a human gate
&lt;/h2&gt;

&lt;p&gt;Read tools can run freely. Anything that changes the cluster stops for a person. Always.&lt;/p&gt;

&lt;p&gt;This rule is enforced in the server, not requested in a prompt. Prompts are suggestions. Server-side gating is a mechanism. An agent that cannot reach a destructive operation without human approval is an agent that can be left running next to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RAG side
&lt;/h2&gt;

&lt;p&gt;Tools cover live state. They do not cover knowledge: runbooks, design documents, postmortems, the accumulated written memory of a platform team. That is the retrieval side of the system.&lt;/p&gt;

&lt;p&gt;Documents are embedded and stored in a vector store. At ask time, the relevant passages are retrieved and handed to the model — Claude, running on Amazon Bedrock — which answers grounded in the retrieved text. The agent ends up with both halves of an operator's brain: the current state of the cluster from tools, and the team's documented experience from retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inference where the data already flows
&lt;/h2&gt;

&lt;p&gt;One more piece fits naturally into this stack. Flink UDFs can run LLM inference in-stream, enriching events as they pass through the pipeline. Not every record needs a model call, and the stream is the right place to decide which ones do. Enrichment happens where the data already flows, instead of in a separate service the data has to be shipped to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An agent operating infrastructure should work through a fixed set of typed tools, never through open-ended access.&lt;/li&gt;
&lt;li&gt;Typed schemas are the contract, and the contract is what keeps a probabilistic caller predictable.&lt;/li&gt;
&lt;li&gt;Operational answers must come from live system state, retrieved at call time — never from model memory.&lt;/li&gt;
&lt;li&gt;Destructive operations belong behind a human gate enforced by the server, not by the prompt.&lt;/li&gt;
&lt;li&gt;Tools give an agent the present. Retrieval gives it the past. A useful operator needs both.&lt;/li&gt;
&lt;li&gt;The model is the easy part. The system around it is the job.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>ai</category>
      <category>mcp</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Events In, Answers Out: One Pipeline From Kafka Stream to Iceberg Table</title>
      <dc:creator>Charan Reddy Rachumalla</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:35:38 +0000</pubDate>
      <link>https://dev.to/charanrachumalla/events-in-answers-out-one-pipeline-from-kafka-stream-to-iceberg-table-2lka</link>
      <guid>https://dev.to/charanrachumalla/events-in-answers-out-one-pipeline-from-kafka-stream-to-iceberg-table-2lka</guid>
      <description>&lt;p&gt;Most streaming diagrams stop at the stream processor. Data flows in, something processes it, and the diagram ends with an arrow pointing at a box labeled "analytics."&lt;/p&gt;

&lt;p&gt;That last box is where the real design work lives. A stream is not something an analyst can query. A table is. The pipeline below is the shape I keep coming back to: sources, Kafka, Flink, Iceberg via TableFlow, Athena, QuickSight. Events in, answers out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline at a glance
&lt;/h2&gt;

&lt;p&gt;Six stages, each doing one job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sources&lt;/strong&gt; — applications and services emitting events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka&lt;/strong&gt; — the topics that hold those events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apache Flink&lt;/strong&gt; — real-time processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iceberg tables via TableFlow&lt;/strong&gt; — stored on S3, cataloged in Glue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Athena&lt;/strong&gt; — plain SQL queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QuickSight&lt;/strong&gt; — dashboards&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No stage reaches around another. Each one consumes the output of the previous stage and produces the input for the next. That discipline is most of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stages 1 and 2: Sources and Kafka
&lt;/h2&gt;

&lt;p&gt;Everything starts with applications and services emitting events — an order placed, a payment attempted, a reading reported. Those events land on Kafka topics.&lt;/p&gt;

&lt;p&gt;Kafka earns its place here for two reasons. It decouples producers from everything downstream, so a source system never waits on analytics and never knows analytics exists. And it retains the events, so the processing layer can re-read history when logic changes or a job restarts from a failure.&lt;/p&gt;

&lt;p&gt;The topic becomes the contract: a named, ordered, replayable record of what happened. Everything downstream trusts the topic instead of trusting the source systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: Flink does the work in real time
&lt;/h2&gt;

&lt;p&gt;Flink reads from the topics and processes events as they arrive. This is where the raw stream gets shaped: noise filtered out, streams joined, events enriched, values aggregated over time windows.&lt;/p&gt;

&lt;p&gt;The important property is timing. The work happens once, at event time, while the data is fresh. The alternative is a batch job that re-reads an entire day of raw events every night and redoes the same shaping work from scratch. By the time records leave Flink, they are already in the shape the table needs.&lt;/p&gt;

&lt;p&gt;State lives in Flink for exactly this reason. A join or a window does not care that the data arrived as a stream. It cares that the right records meet each other, and Flink holds them until they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: TableFlow lands the stream in Iceberg
&lt;/h2&gt;

&lt;p&gt;This is the stage that changed the architecture for me. TableFlow materializes the processed stream as an Apache Iceberg table, continuously. The data files live in Amazon S3. The table is registered in the Glue Catalog, and that catalog entry is what makes it visible to the rest of the analytics stack.&lt;/p&gt;

&lt;p&gt;Iceberg matters because it gives files on object storage real table semantics: a defined schema that can evolve, snapshots of the table at points in time, and safe reads while new data keeps arriving. The stream stops being a stream at exactly this point. It becomes a table, and it stays current because the materialization never stops.&lt;/p&gt;

&lt;p&gt;The practical win is what disappears. There is no separate ETL job copying Kafka data into a warehouse on a schedule. There is no second version of the pipeline maintained for the analytics side, drifting out of sync with the streaming version. The streaming path and the table are the same path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stages 5 and 6: Athena and QuickSight
&lt;/h2&gt;

&lt;p&gt;With the table in the Glue Catalog, Athena queries it with plain SQL. There is no cluster to size and no data to load. The query runs where the data already sits, on S3.&lt;/p&gt;

&lt;p&gt;QuickSight sits on top of that for dashboards. The people consuming the data never touch Kafka or Flink. They open a dashboard, or run a SQL query against a table that was a stream moments earlier.&lt;/p&gt;

&lt;p&gt;That separation is the point of the whole design. The streaming stack does streaming work. The analytics stack does analytics work. Iceberg and TableFlow are the seam between them, and the seam is a table format and a catalog — boring, standard, inspectable pieces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A streaming pipeline is only finished when the data lands somewhere queryable. The last box on the diagram deserves the most design attention.&lt;/li&gt;
&lt;li&gt;Kafka works best as the durable contract between producers and processing: ordered, replayable, and decoupled from both sides.&lt;/li&gt;
&lt;li&gt;Processing in Flink at event time removes the nightly batch re-processing step entirely.&lt;/li&gt;
&lt;li&gt;Materializing a stream into Iceberg with TableFlow makes one pipeline serve as both the streaming path and the analytics path.&lt;/li&gt;
&lt;li&gt;Analysts should query tables with SQL. They should never need to know a stream exists underneath.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kafka</category>
      <category>flink</category>
      <category>dataengineering</category>
      <category>streamprocessing</category>
    </item>
  </channel>
</rss>
