<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://www.dataharness.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.dataharness.org/" rel="alternate" type="text/html" /><updated>2026-02-06T00:13:49+00:00</updated><id>https://www.dataharness.org/feed.xml</id><title type="html">DataHarness</title><subtitle>A unified abstraction layer for multiple table sources</subtitle><entry><title type="html">Iceberg Sucks - But You Knew That Already</title><link href="https://www.dataharness.org/2026/01/01/iceberg-sucks.html" rel="alternate" type="text/html" title="Iceberg Sucks - But You Knew That Already" /><published>2026-01-01T00:00:00+00:00</published><updated>2026-01-01T00:00:00+00:00</updated><id>https://www.dataharness.org/2026/01/01/iceberg-sucks</id><content type="html" xml:base="https://www.dataharness.org/2026/01/01/iceberg-sucks.html"><![CDATA[<h2 id="motivation-behind-the-dataharness">Motivation behind the DataHarness</h2>

<p>In my last year of work as a data developer at a high-frequency trading company, I’ve begun fighting a battle that may
sound familiar to many - trying to get Apache Iceberg tables to support frequent low latency updates. While the title
of this article makes it seem like I’m about to spend the next few pages shitting on Iceberg, I actually think it’s a
great technology. It offers many improvements over Hive tables, and using an open format is critical for avoiding
vendor/query engine lock-in, something which my company currently struggles with.</p>

<p>That being said, for our data cases that require frequent/streaming writes from many processes, Iceberg has proven to be
more of a pain in the ass than an angry ex-wife. To commit data to an iceberg table, you need to:</p>

<ul>
  <li>Write data/delete files to object store</li>
  <li>Write manifest files to object store</li>
  <li>Write manifest list files to object store</li>
  <li>Write a snapshot file to object store (completely copying most of the old one)</li>
  <li>Update the catalog to point to the new snapshot</li>
</ul>

<p>This doesn’t lend itself well to fast data appends. Additionally, if you have multiple concurrent writes, all but one
will fail and need to be retried. Einstein is misattributed with a quote that says trying the same thing over and over
again and expecting a different outcome is the mark of insanity. But in the Iceberg world, it’s actually the norm.</p>

<p>Here are some reasons why that’s stupid:</p>

<ul>
  <li>If all writers are doing append-only writes, we’ll still fail to commit</li>
  <li>If all writers are writing to different partitions, we’ll still fail to commit</li>
  <li>Optimistic locking means that writers will retry rather than waiting in a queue until other writers are done</li>
  <li>All of these small files are getting added to object store, meaning your company is going to burn all of your precious
VC money on your S3 bill and your queries will be
slow (<a href="https://aws.amazon.com/s3/pricing/">5 cents per thousand operations</a>?? I’d rather waste my money on
OnlyFans!)</li>
</ul>

<p>To mitigate the number of writes to iceberg (or other lakehouse formats), and by proxy the write amplification factor,
the common pattern in the data space is to buffer writes in a message broker or transactional database (and then use
<a href="https://debezium.io/">debezium</a> plus a message broker to sink the data to iceberg). Great, so now you’ve solved one
problem, but have created another - how do I achieve exactly-once semantics?</p>

<p>This is where stream processing frameworks come in. Developers often
use <a href="https://iceberg.apache.org/docs/1.4.3/flink-connector/">Flink</a>
or <a href="https://iceberg.apache.org/docs/nightly/kafka-connect/">Kafka-Connect</a>, both of which have a
relatively steep learning curve (compared to writing your own application in the language of your choice) that feels
somewhat unnecessary when writing simple data pipelines with no data transformations. Is that really all so necessary
when you really just want to keep track of the last processed kafka offsets for a current iceberg snapshot?</p>

<p>Finally, Iceberg makes updating and deleting data a real challenge. In Iceberg, you currently
have <a href="https://amdatalakehouse.substack.com/p/understanding-apache-iceberg-delete?utm_medium=reader2">two options</a>:</p>

<ul>
  <li>Positional based (either via tuple or vector) row invalidations that target one row of a file</li>
  <li>Conditions that invalidate any row in a partition that matches them</li>
</ul>

<p>There are some issues here, too:</p>

<ul>
  <li>Positional deletes are not write optimized and significantly slow down streaming writes</li>
  <li>Equality deletes slow read queries to a halt, and can only be mitigated with expensive data compactions</li>
  <li>“Partial” row updates are not yet possible</li>
</ul>

<p>While I clearly have my gripes with Apache Iceberg, for the sake of being intellectually honest, I’d say: the reason
that people have issues with Iceberg is that they’re trying to force it to solve problems it’s not meant to solve.
Iceberg is not an OLTP database. It is not meant for quick updates to single rows at low latencies, and those trying to
force it to be are in for a rude awakening.</p>

<p>In the past few years, I’ve seen the proliferation of many technologies devoted to the improvement of fast inserts for
analytical data:</p>

<ul>
  <li><a href="https://hudi.apache.org/">Apache Hudi</a></li>
  <li><a href="https://paimon.apache.org/">Apache Paimon</a></li>
  <li><a href="https://fluss.apache.org/">Apache Fluss</a></li>
  <li><a href="https://www.mooncake.dev/">Mooncake</a></li>
  <li><a href="https://www.peerdb.io/">PeerDB</a></li>
  <li><a href="https://buf.build/docs/bufstream/iceberg/">Buf</a>/<a href="https://aiven.io/blog/iceberg-topics-for-apache-kafka-zero-etl-zero-copy">Aiven</a>
/<a href="https://www.confluent.io/product/tableflow/">Confluent</a>/<a href="https://docs.redpanda.com/current/manage/iceberg/about-iceberg-topics/">RedPanda</a>
kafka/iceberg topics</li>
  <li><a href="https://ducklake.select/">DuckLake</a> for cutting out object storage for metadata</li>
</ul>

<p>And I’m sure I’m missing many others. Many of these have common traits, such as merging memory/local NVMe with object
storage, trying to integrate message brokers into iceberg tables directly, or using some form of arrow buffer to unify
recently written data with cold data. And most importantly, all of their developers are smarter than me.</p>

<p>At the end of the day though, the specific technology doesn’t matter. It should be clear to all of us that when we want
to solve a data problem that involves both transactional and analytical data, we need to integrate both transactional
and analytical data stores. Do we really need a different startup for unifying every combination of OLTP database and
OLAP data warehouse? Or should we just make it easier to make them composable?</p>

<p>Instead of focusing on building another HTAP database, I’d rather focus on building a general “data system unifier” to
allow developers to fuse data systems together for their specific use case. In the next 10 years, I’m 100% certain that
every major data tool
is <a href="https://github.com/fede1024/rust-rdkafka">going to get rewritten in rust about 15 different times</a>
(<a href="https://news.ycombinator.com/item?id=45747018">and probably also implemented with Postgres</a>). Rather than follow that
trend, I want data developers like myself to be able to pick and choose any combination of these sources to make up a
“table”. In that case, what properties should our “unifier” satisfy?</p>

<ul>
  <li><strong>Transactional semantics when moving data between sources</strong>: We shouldn’t see duplicate data or zero data in our “
table” when performing change data capture (i.e. postgres data becomes cold, delete it and move it to iceberg) or
reverse change data capture (i.e. delete data from Iceberg and send to postgres since it’s about to be updated many
times)</li>
  <li><strong>Atomic schema evolutions</strong>: Schema changes should reflect in all data sources at the same time</li>
  <li><strong>State alignment for sources that are not visible in the table itself</strong>: Even if you’re using Flink/other
technologies as your main stream consumer, having a system to externally expose Flink checkpoint state allows query
engines to read from sink table versions that are consistent with the checkpoint - this also means having the ability
to centralize flink state, kafka consumer offsets, and sink table version in the same place, avoiding two phase
commits</li>
  <li><strong>Integration with all popular query engines</strong>: Spark, Trino, Presto, ClickHouse, Starrocks, Flink, hopefully closed
source ones too</li>
  <li><strong>Support for a wide range of table data sources</strong>: Kafka brokers, OLTP databases, arrow flight servers, open
lakehouse formats, and static avro/parquet/orc files - all of this content can be combined as one table</li>
  <li><strong>Allowing raw files</strong> enables brave developers to build their own lakehouse formats on top of this “table unifier” by
writing additional scan planning logic</li>
  <li><strong>Projection/predicate pushdown</strong>: Use existing query engine logic for each data source to run efficient queries</li>
  <li><strong>Fine grained locking across data sources</strong>: Allows many writers to make concurrent changes to disjoint data sources</li>
  <li><strong>Partitioning</strong>: Specify logic for how data is split across sources to perform query-time source pruning</li>
</ul>

<p>Instead of trying to build an HTAP system myself, I’ll let our database and data warehouse engineers do what they do
best, and instead try to simplify the process for engineers like myself to wire them together. Let’s call this
“open composition layer” the DataHarness. When BasementDweller2048 creates a message broker in assembly next year, I
look forward to welcoming his software to the fleet of available DataHarness sources.</p>

<h2 id="utility-of-the-dataharness">Utility of the DataHarness</h2>

<p><strong>Simple Example:</strong></p>

<p>Scenario:</p>

<ul>
  <li>You want to provide low latency analytical data of your logs</li>
  <li>Your servers publish logs to many partitions of a kafka topic</li>
  <li>Consumer servers read from kafka, convert the data to parquet, and commit to iceberg</li>
  <li>If you publish too often, query performance will be bad, so you flush every 10 minutes, which is too long</li>
  <li>You read the data from Spark and Trino</li>
  <li>You’d love to have them read from both kafka and the iceberg table, but you have two problems:
    <ul>
      <li>You can’t evolve their schemas at the same time, which breaks the union view</li>
      <li>Depending on whether your query engine reads from kafka or iceberg first, you may see missing or duplicate rows
        <ul>
          <li>This is because they can’t be read at a consistent snapshot in time</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<p>Benefits of the DataHarness:</p>

<ul>
  <li>Your consumer servers can update the DataHarness every couple of seconds with the highest known kafka offset per
partition</li>
  <li>When the consumer servers flush data, they’ll atomically tell the DataHarness:
    <ul>
      <li>The new “snapshot ID” of the Iceberg table</li>
      <li>The new “low offset” of the Kafka topic partition that was flushed (we don’t want to re-read data that is now in
iceberg)</li>
    </ul>
  </li>
  <li>When spark/trino query your table, they’ll union data from both kafka and iceberg without any duplicates or drops</li>
</ul>

<p><img src="/assets/images/simple.png" alt="simple" width="1000" /></p>

<p><strong>Medium Example:</strong></p>

<p>Scenario:</p>

<ul>
  <li>You have the same situation as the simple example, but now your logs may get replaced by other messages with the same
log ID for up to 10 minutes</li>
  <li>Your kafka queue uses the ID as its message key, so any updates to a log live in the same kafka partition</li>
  <li>From kafka, you insert data into postgres, using a primary key to update rows where necessary</li>
  <li>After 10 minutes, you flush postgres data to iceberg to minimize the amount of row updates</li>
  <li>The server performing updates between postgres and iceberg uses equality deletes to invalidate the old version of the
row in iceberg</li>
  <li>You only query the iceberg table, so data needs to be buffered for 10 minutes before ir is visible to clients</li>
</ul>

<p>Benefits of the DataHarness:</p>

<ul>
  <li>Data can be sourced from kafka, postgres, and iceberg with transactional semantics</li>
  <li>The server moving data from kafka to postgres updates kafka offsets/postgres read timestamps in the DataHarness</li>
  <li>The server moving data from postgres to iceberg updates postgres/iceberg read timestamps in the DataHarness</li>
</ul>

<p>Note that they both modify the current <a href="https://github.com/nearform/temporal_tables">“read timestamp”</a> of the Postgres
table! This could lead to some nasty race
conditions!
Instead, before exchanging any data between data sources, they:</p>

<ul>
  <li>Grab a lock in the DataHarness on the Postgres source</li>
  <li>Move rows from kafka to postgres, or Postgres to Iceberg</li>
  <li>Update the state of each data source</li>
  <li>Release the lock</li>
</ul>

<p>Finally:</p>

<ul>
  <li>Spark/Trino read the DataHarness to get the appropriate source offsets/read timestamps, and then query them</li>
  <li>Using simple SQL, we can take our union view and create an auxiliary “primary key view” which deduplicates rows</li>
</ul>

<p><img src="/assets/images/medium.png" alt="medium" width="1000" /></p>

<p><strong>Expert Example:</strong></p>

<p>Scenario:</p>

<ul>
  <li>Same as before, except you need more than a single Postgres instance to support your incoming kafka load</li>
  <li>Multiple kafka topic partitions</li>
  <li>Shard out your Postgres table using <a href="https://github.com/citusdata/citus">Citus</a></li>
  <li>Commit to many LakeHouse partitions at once using <a href="https://github.com/citusdata/citus">Apache Paimon</a></li>
  <li>Ensure that each kafka partition’s data goes to one postgres partition which goes to one paimon partition</li>
  <li>Query the data with Spark (Paimon doesn’t work with Trino)</li>
</ul>

<p>Benefits of the DataHarness:</p>

<ul>
  <li>Use a different “source” for each partition of the kafka topic, postgres table, and paimon table</li>
  <li>Complete concurrency without any synchronization across partitions, DataHarness locks can be grabbed for one partition
at a time</li>
  <li>Kafka offsets, postgres read timestamps, and paimon read timestamps are all modified one partition at a time</li>
</ul>

<p><img src="/assets/images/expert.png" alt="expert" width="1000" /></p>

<p><strong>Basement Dweller Example:</strong></p>

<ul>
  <li>Same as the medium example</li>
  <li>Instead of using Iceberg, you use DuckLake</li>
</ul>

<p>Benefits of the DataHarness:</p>

<ul>
  <li>You can co-locate your OLTP data, DuckLake metastore, and DataHarness in the same Postgres instance</li>
  <li>Performing committing a CDC operation between Postgres and DuckLake just takes a single database transaction</li>
</ul>

<p><img src="/assets/images/dweller.png" alt="dweller" width="1000" /></p>

<h2 id="conclusion">Conclusion</h2>

<p>As you can see, there are benefits to composability!</p>

<p>Well, this turned into more of a manifesto than expected. Seems like we’ve got a lot of ground to cover.</p>]]></content><author><name>Jordan Epstein</name></author><summary type="html"><![CDATA[Why we need a DataHarness.]]></summary></entry></feed>