Skip to content

iceberg: bring back in-manifest stats - #31332

Merged
andrwng merged 10 commits into
redpanda-data:devfrom
andrwng:iceberg-manifest-stats
Aug 7, 2026
Merged

iceberg: bring back in-manifest stats#31332
andrwng merged 10 commits into
redpanda-data:devfrom
andrwng:iceberg-manifest-stats

Conversation

@andrwng

@andrwng andrwng commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reverts the revert and adds a few more fixes to make it slightly safer in certain pathological cases:

  • In the coordinator, start counting the iobufs in the stats in the coordinator and apply backpressure based on this metric, similar to how we apply backpressure based on the number of files. The default is to apply a soft limit of 32MiB.
  • Similarly, when we select groups of files to commit, only collect up to a certain memory (default is 32MiB), similar to how we commit in chunks.
  • On the translator, per parquet writer, start accounting for the idle memory of each column: previously we were using a fixed value per writer, but that isn't accurate when considering schemas with many fields.
  • Similarly, add the heap memory of the stats collector to the memory usage accounting.

Backports Required

  • none - not a bug fix
  • none - this is a backport
  • none - issue does not exist in previous branches
  • none - papercut/not impactful enough to backport
  • v26.2.x
  • v26.1.x
  • v25.3.x

Release Notes

  • None

Copilot AI review requested due to automatic review settings July 29, 2026 05:00
@andrwng
andrwng requested a review from a team as a code owner July 29, 2026 05:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the datalake → Iceberg pipeline to propagate Parquet per-column statistics (bounds/counts/sizes) into Iceberg manifest entries, and introduces byte-based bounding/backpressure to keep coordinator/commit memory usage under control.

Changes:

  • Extract aggregated file-level Parquet column statistics and plumb them through local_file_metadata → coordinator data_file → Iceberg manifest metrics.
  • Add byte-based caps for commit chunking and coordinator pending backlog (new config tunables + derived-state accounting in coordinator STM).
  • Add/extend unit + e2e tests covering parquet stats correctness, manifest serialization, and snapshot/backpressure behavior.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/rptest/tests/datalake/datalake_e2e_test.py Adds Spark-based e2e assertion that manifest .files readable_metrics are populated correctly.
src/v/serde/parquet/writer.h Exposes file-level aggregated column stats from Parquet writer.
src/v/serde/parquet/writer.cc Accumulates file-level value counts/sizes and returns per-column file stats.
src/v/serde/parquet/column_writer.h Adds API to return aggregated per-column stats across row groups.
src/v/serde/parquet/column_writer.cc Implements file-level stats aggregation/merge across flushes/row groups.
src/v/serde/parquet/BUILD Makes parquet metadata library publicly visible for downstream use.
src/v/datalake/translation_task.cc Plumbs local parquet column stats into coordinator data_file upload metadata.
src/v/datalake/tests/test_data_writer.h Fixes test writer to move results out (avoid copies).
src/v/datalake/tests/serde_parquet_writer_test.cc Adds unit tests validating stats encoding and cross-row-group aggregation.
src/v/datalake/tests/BUILD Adds deps needed by new parquet stats unit tests.
src/v/datalake/serde_parquet_writer.h Stores/returns extracted per-column stats post-finish.
src/v/datalake/serde_parquet_writer.cc Extracts Parquet footer stats into per_column_stats for datalake usage.
src/v/datalake/local_parquet_file_writer.cc Stores per-column stats into local_file_metadata after finish.
src/v/datalake/data_writer_interface.h Extends parquet writer interface with optional column_stats() API.
src/v/datalake/coordinator/translated_offset_range.h Adds estimated-memory helper for pending range accounting.
src/v/datalake/coordinator/tests/state_update_test.cc Updates tests for new copy_bounded(max_files, max_bytes, ...) signature + adds pending totals test.
src/v/datalake/coordinator/tests/state_machine_test.cc Adds snapshot hydration test ensuring derived pending totals are recomputed.
src/v/datalake/coordinator/tests/iceberg_file_committer_test.cc Adds tests for manifest column stats + byte-based commit chunking; updates committer ctor usage.
src/v/datalake/coordinator/tests/data_file_test.cc New tests for estimated-memory sizing behavior with/without column stats.
src/v/datalake/coordinator/tests/coordinator_test.cc Adds coordinator backpressure test driven by byte limit.
src/v/datalake/coordinator/tests/BUILD Adds new gtest target and dependencies for added coordinator tests.
src/v/datalake/coordinator/state.h Adds byte-based bounded copy + derived pending totals API.
src/v/datalake/coordinator/state.cc Implements byte-bounded copy + running totals + recompute after snapshot install.
src/v/datalake/coordinator/state_update.cc Maintains running pending totals on add/commit/purge/reset updates.
src/v/datalake/coordinator/state_machine.cc Recomputes derived pending totals when applying snapshots.
src/v/datalake/coordinator/iceberg_file_committer.h Adds max_bytes_per_commit binding to committer.
src/v/datalake/coordinator/iceberg_file_committer.cc Writes column metrics into Iceberg manifests + uses byte-bounded copy_bounded.
src/v/datalake/coordinator/data_file.h Adds serialized column_stats to coordinator data_file + memory estimate helper.
src/v/datalake/coordinator/coordinator.h Adds byte-based pending backlog limit binding to coordinator.
src/v/datalake/coordinator/coordinator.cc Uses derived pending totals (files + bytes) for backpressure decision.
src/v/datalake/coordinator/coordinator_manager.cc Wires new config bindings into coordinator and committer constructors.
src/v/datalake/coordinator/BUILD Updates deps for coordinator build targets due to new types/usage.
src/v/datalake/BUILD Adds base_types + parquet metadata deps for parquet stats plumbing.
src/v/datalake/base_types.h Introduces per_column_stats + adds column stats to local_file_metadata.
src/v/config/configuration.h Declares new tunables for byte-based commit/pending caps.
src/v/config/configuration.cc Defines defaults/help strings for new datalake byte-based tunables.

Comment on lines 137 to 139
_buffered_bytes = _flushed_bytes = 0;
co_return writer_error::ok;
}
Comment on lines +129 to +134
if (cs.bounds.min) {
ps.lower_bound = iobuf_to_bytes(cs.bounds.min->value.copy());
}
if (cs.bounds.max) {
ps.upper_bound = iobuf_to_bytes(cs.bounds.max->value.copy());
}
Comment on lines 187 to 195
coordinator::data_file uploaded{
.remote_path = r.value()().string(),
.row_count = file.local_file.row_count,
.file_size_bytes = file.local_file.size_bytes,
.table_schema_id = file.schema_id,
.partition_spec_id = file.partition_spec_id,
.partition_key = std::move(pk_fields),
.column_stats = file.local_file.column_stats.copy(),
};
@vbotbuildovich

vbotbuildovich commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

CI test results

test results on build#87868
test_status test_class test_method test_arguments test_kind job_url passed reason test_history
FLAKY(PASS) ShadowLinkTopicFailoverTests test_producer_ids_failover {"storage_mode": "tiered_v1"} integration https://buildkite.com/redpanda/redpanda/builds/87868#019fac5a-30b7-43ec-8eb4-28b553a4c609 10/11 Test PASSES after retries.No significant increase in flaky rate(baseline=0.0114, p0=1.0000, reject_threshold=0.0100. adj_baseline=0.1000, p1=0.3487, trust_threshold=0.5000) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=ShadowLinkTopicFailoverTests&test_method=test_producer_ids_failover
FLAKY(PASS) ShadowLinkingMetricsTests test_link_metrics null integration https://buildkite.com/redpanda/redpanda/builds/87868#019fac5a-30ba-40c1-b277-61843cfb071f 10/11 Test PASSES after retries.No significant increase in flaky rate(baseline=0.0031, p0=1.0000, reject_threshold=0.0100. adj_baseline=0.1000, p1=0.3487, trust_threshold=0.5000) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=ShadowLinkingMetricsTests&test_method=test_link_metrics
FLAKY(PASS) NodeWiseRecoveryTest test_recovery_local_data_missing {"wait_for_final_manifest_uploads": true} integration https://buildkite.com/redpanda/redpanda/builds/87868#019fac5a-30b7-43ec-8eb4-28b553a4c609 10/11 Test PASSES after retries.No significant increase in flaky rate(baseline=0.0182, p0=1.0000, reject_threshold=0.0100. adj_baseline=0.1000, p1=0.3487, trust_threshold=0.5000) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=NodeWiseRecoveryTest&test_method=test_recovery_local_data_missing
FLAKY(PASS) SimpleEndToEndTest test_relaxed_acks {"write_caching": false} integration https://buildkite.com/redpanda/redpanda/builds/87868#019fac5c-8104-4901-9013-8e6f9aa3a686 10/11 Test PASSES after retries.No significant increase in flaky rate(baseline=0.0054, p0=1.0000, reject_threshold=0.0100. adj_baseline=0.1000, p1=0.3487, trust_threshold=0.5000) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=SimpleEndToEndTest&test_method=test_relaxed_acks
test results on build#87960
test_status test_class test_method test_arguments test_kind job_url passed reason test_history
FAIL ParquetWriter ColumnMemoryEstimateCoversActual unit https://buildkite.com/redpanda/redpanda/builds/87960#019fb0ea-720c-428e-91de-83881b9c2197 0/1
test results on build#88390
test_status test_class test_method test_arguments test_kind job_url passed reason test_history
FAIL MasterTestSuite per_entity_probe_deregistered_on_gc unit https://buildkite.com/redpanda/redpanda/builds/88390#019fd934-903a-4f6b-a5d2-378816796659 0/1

@andrwng
andrwng force-pushed the iceberg-manifest-stats branch from 1c96301 to 9c0c7c6 Compare July 30, 2026 08:51
@andrwng

andrwng commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

/ci-repeat 1
dt-repeat=10
skip-redpanda-build
skip-units
skip-rebase
tests/rptest/tests/datalake/custom_partitioning_test.py::DatalakeCustomPartitioningTest.test_many_partitions@{"catalog_type":"rest_jdbc","cloud_storage_type":1}

@andrwng

andrwng commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

/ci-repeat 10
dt-repeat=20
skip-redpanda-build
skip-units
skip-rebase
tests/rptest/tests/datalake/custom_partitioning_test.py::DatalakeCustomPartitioningTest.test_many_partitions@{"catalog_type":"rest_jdbc","cloud_storage_type":1}

@andrwng andrwng changed the title wip: iceberg manifest stats redux iceberg: bring back in-manifest stats Jul 31, 2026
@wdberkeley
wdberkeley self-requested a review August 3, 2026 15:52
wdberkeley
wdberkeley previously approved these changes Aug 3, 2026

@wdberkeley wdberkeley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. This is a big improvement.

Comment thread src/v/datalake/coordinator/state.cc Outdated
Comment thread src/v/config/configuration.cc Outdated
state.pending_files() >= max_pending_files_()
|| state.pending_bytes() >= max_pending_bytes_()) {
backpressured_as_of_ = now;
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

has_too_many_pending_files is technically a misnomer as we may have few files but their pending state takes too much memory. If you can come up with a good name it's worth renaming, but it's also ~fitting.

Also is it worth distinguishing (for an error message, metric) the cause of the backpressure, returning an enum or error code instead of a boolean?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only did a rename, opted out of an enum/error code. Seemed like kind of overkill but can add it if you feel strongly about it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't feel strongly. Fine as is.

Comment thread src/v/config/configuration.cc Outdated
Comment thread src/v/datalake/coordinator/state.cc Outdated
// The estimate models seastar-allocator resident bytes, which is what the
// translator's memory semaphore admits writers against, so the measurement is
// only meaningful in builds that link that allocator.
TEST(ParquetWriter, ColumnMemoryEstimateCoversActual) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is very Claude. It's a little sus but if it doesn't flake or act weird it's nice to have.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea kinda agree, but it's pretty consistent it seems. And it's useful for picking a reasonable estimate (I think)

Comment thread src/v/serde/parquet/column_writer.cc Outdated
// run that test (it prints the measured per-leaf on failure) and set this
// so the estimate falls back in range. Absolute bytes are allocator
// dependent, so the test compares a large-N slope, not an absolute figure.
constexpr size_t largest_column_object = std::max({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny to see the AI go to such lengths with the test and the comment to guard the code against drift of the estimate, but this list would silently go stale if we add a physical type. 🤷‍♂️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, fair. The whole memory estimation is empirical anyway so I removed this.

@andrwng
andrwng force-pushed the iceberg-manifest-stats branch from 9c0c7c6 to 09aa654 Compare August 5, 2026 07:10
@andrwng
andrwng requested a review from wdberkeley August 5, 2026 07:18
@andrwng
andrwng force-pushed the iceberg-manifest-stats branch 2 times, most recently from 29b9ad4 to 4fbc545 Compare August 6, 2026 22:13
wdberkeley and others added 10 commits August 6, 2026 15:16
Thread per-column stats (min/max bounds, null counts, value counts,
column sizes) through to the Iceberg data_file manifest entry, where
query engines use them for column-level predicate pushdown.

Maintain a file-level column_stats_collector in buffered_column_writer
that accumulates by merging after each flush_pages(), then use the result
after the file is done to get file-level stats.

(cherry picked from commit 7f83353)
Adds estimated_memory_bytes() for data_file and translated_offset_range.
For use by a subsequent commit that bounds the coordinator's pending state
by memory rather than file count.
has_too_many_pending_files() scans every pending entry on each request.
Rather than keep scanning as we start to also account memory, maintain
running totals on topics_state, updated as entries enter and leave pending
state.

The totals are derived, so they're left out of serde_fields() and rebuilt
by recompute_pending() on snapshot install. Underflow is clamped rather
than left to wrap: a wrapped total would sit above every backpressure
limit and reject adds for the whole coordinator until the next recompute.

For use by subsequent commits that shed load and chunk commits by memory.
A pending file's footprint varies widely with its column stats, so a file
count is a poor proxy for the memory has_too_many_pending_files() is meant
to bound.

Adds datalake_coordinator_max_pending_bytes (soft, 32 MiB) and sheds when
either total is over its limit. Reads the running totals, so the check is
O(1) rather than a scan.

The ducktape case puts the file count out of reach so nothing but the byte
limit can reject a request, which makes the backpressure metrics on their
own proof that the path works.
copy_bounded() caps the files copied into a single commit pass to bound its
transient memory, but the same file count can hold far more memory once
files carry column stats. Adds datalake_coordinator_max_bytes_per_commit
(soft, 32 MiB) and stops the copy once either limit is hit.
Adds writer::estimated_memory(num_leaf_columns): sizeof(impl) plus a
per-column term. That term is empirical, covering the column writer object,
the chunks its containers take on the first row, the schema element and
allocator rounding.

ColumnMemoryEstimateCoversActual reports what has to be covered:

    estimated_column_memory() needs at least 2920 to cover a byte_array
    column; it is 4096

The slope holds near 2900 whether the second measurement is 2000 or 10000
columns, so 4KiB leaves about 40% over it. It only measures where seastar's
allocator is linked, and skips elsewhere.

For use by the datalake writer memory reservation in a subsequent commit.
Each open parquet writer costs the output stream buffer, held until close,
plus a column writer per leaf column. A high-cardinality partition spec
opens a writer per partition value, which can be far more than the datalake
memory pool holds.

The reservation is released on flush, so writers that stay open past one
still hold memory the pool no longer accounts for.
column_writer::memory_usage() drives the translator's memory reservation
but counted only buffered page data. Each leaf column also retains the
row group's min/max bounds untruncated, which for byte-array columns
grows with the value width.
iobuf_to_bytes() takes its argument by const ref and copies into a fresh
bytes, so copying the bound first was an extra allocation and memcpy per
column on every file finish.
@andrwng
andrwng force-pushed the iceberg-manifest-stats branch from 4fbc545 to 5e64e28 Compare August 6, 2026 22:31
@andrwng

andrwng commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@andrwng
andrwng merged commit ad75461 into redpanda-data:dev Aug 7, 2026
19 checks passed
@vbotbuildovich

Copy link
Copy Markdown
Collaborator

/backport v26.2.x

@vbotbuildovich

Copy link
Copy Markdown
Collaborator

/backport v26.1.x

@vbotbuildovich

Copy link
Copy Markdown
Collaborator

/backport v25.3.x

@vbotbuildovich

Copy link
Copy Markdown
Collaborator

Failed to create a backport PR to v25.3.x branch. I tried:

git remote add upstream https://github.com/redpanda-data/redpanda.git
git fetch --all
git checkout -b backport-pr-31332-v25.3.x-70 remotes/upstream/v25.3.x
git cherry-pick -x ddb4ace072 fcad9e1362 f3cfe07a0e 9a98ac5ee1 e12338af5b cb7ebf5905 5497e8fc15 3eaf8279bc babc950f87 5e64e28bf6

Workflow run logs.

@vbotbuildovich

Copy link
Copy Markdown
Collaborator

Failed to create a backport PR to v26.1.x branch. I tried:

git remote add upstream https://github.com/redpanda-data/redpanda.git
git fetch --all
git checkout -b backport-pr-31332-v26.1.x-755 remotes/upstream/v26.1.x
git cherry-pick -x ddb4ace072 fcad9e1362 f3cfe07a0e 9a98ac5ee1 e12338af5b cb7ebf5905 5497e8fc15 3eaf8279bc babc950f87 5e64e28bf6

Workflow run logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants