Skip to content

storage: fix compaction minting future record timestamps from strictly-past input#30998

Open
Aangbaeck wants to merge 2 commits into
redpanda-data:devfrom
Aangbaeck:fix/compaction-future-timestamp-mint
Open

storage: fix compaction minting future record timestamps from strictly-past input#30998
Aangbaeck wants to merge 2 commits into
redpanda-data:devfrom
Aangbaeck:fix/compaction-future-timestamp-mint

Conversation

@Aangbaeck

@Aangbaeck Aangbaeck commented Jul 2, 2026

Copy link
Copy Markdown

When compaction drops some of a batch's records, the batch is re-encoded.
The old code advanced the new first_timestamp to the first surviving
record but left the per-record timestamp deltas unchanged (still relative to
the batch's original first_timestamp). On read, each record then resolves
to new_first_timestamp + old_delta, i.e. shifted forward by the dropped
base record's delta. With a tombstone dropping the base of an out-of-order
batch, that shift is large and positive, so strictly-past records read back
with timestamps months in the future.

Fix: re-base the surviving deltas onto the new first_timestamp, the same way
Kafka does in MemoryRecordsBuilder.appendWithOffset. Added
record::set_timestamp_delta() to keep _size_bytes in sync with the
re-encoded vint width. Applied to both compaction paths
(copy_data_segment_reducer::filter and compaction::filter::do_filter_batch).

Regression tests build an out-of-order batch whose delta-0 base record is
compacted away, run compaction, and assert record timestamps are unchanged and
max_timestamp equals the greatest surviving timestamp. They fail on the old
reducer and pass with the fix (compaction/tests/reducer_test.cc for the
do_filter_batch path, storage/tests/compaction_e2e_test.cc for the
sliding-window path).

Notes:

  • max_timestamp is the new first_timestamp plus the greatest surviving
    re-based delta, i.e. the greatest surviving absolute timestamp. (The previous
    code tracked the last delta, which is wrong for out-of-order batches.)
  • Offsets are intentionally not re-based: base_offset is preserved as before,
    matching the DefaultRecordBatch producer-state semantics already documented in
    the reducer.

Copilot AI review requested due to automatic review settings July 2, 2026 13:01

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 fixes a log-compaction re-encode bug where partially-compacted batches could end up with a corrupted (shifted) batch base timestamp, causing records that were produced with strictly-past timestamps to be read back with future timestamps after compaction. The fix keeps per-record timestamp deltas unchanged while preserving the original batch first_timestamp, and recomputes max_timestamp using the maximum surviving timestamp delta (rather than assuming record iteration order matches timestamp order).

Changes:

  • Track the maximum surviving timestamp_delta during batch filtering (rather than the first/last surviving deltas).
  • Preserve the original record_batch_header.first_timestamp during compaction re-encode to keep unchanged deltas semantically valid.
  • Recompute max_timestamp from first_timestamp + max_surviving_timestamp_delta for CREATE_TIME batches.

Comment thread src/v/storage/compaction_reducers.cc Outdated
Comment thread src/v/storage/compaction_reducers.cc
@CLAassistant

CLAassistant commented Jul 2, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@Aangbaeck Aangbaeck force-pushed the fix/compaction-future-timestamp-mint branch from 00062db to b5bbd44 Compare July 2, 2026 13:34
@WillemKauf WillemKauf self-requested a review July 2, 2026 17:38

@WillemKauf WillemKauf 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.

Thanks for the PR and raising the issue - though I wish the cover letter was hand written and not paragraphs of AI (the issue here can be summarized in a few sentences at most).

I also have some thoughts on how we should be doing the right thing (e.g. mirroring Apache Kafka compaction behavior) here.

Comment thread src/v/storage/tests/compaction_reducer_timestamp_test.cc Outdated
Comment thread src/v/storage/compaction_reducers.cc Outdated
Comment thread src/v/storage/compaction_reducers.cc Outdated
Comment thread src/v/storage/compaction_reducers.cc Outdated
@WillemKauf

Copy link
Copy Markdown
Contributor

Also, we'll need to apply the logic of this fix to compaction::filter::do_filter_batch() as well.

co_await b.for_each_record_async([&rec_count,
&first_timestamp_delta,
&last_timestamp_delta,
&ret,
&keep_idx,
&offset_deltas](model::record record) {
// contains the key
if (
keep_idx < offset_deltas.size()
&& offset_deltas[keep_idx] == record.offset_delta()) {
++keep_idx;
/*
* TODO when we further optimize lazy record materialization ot
* make use of views we can avoid this re-encoding by copying or
* sharing the view. either way, we were building
* record batch with the uncompressed records so they were being
* re-encoded.
*/
if (!first_timestamp_delta) {
first_timestamp_delta = record.timestamp_delta();
}
last_timestamp_delta = record.timestamp_delta();
model::append_record_to_buffer(ret, record);
++rec_count;
}
});

@WillemKauf

Copy link
Copy Markdown
Contributor

Commits like b9090c2 also shouldn't be in the PR

When compaction drops some of a batch's records, the batch is re-encoded.
The old code advanced the new first_timestamp to the first surviving
record but left the per-record timestamp deltas unchanged (still relative
to the batch's original first_timestamp). On read, every surviving record
then resolves to new_first_timestamp + old_delta -- shifted forward by the
dropped base record's delta.

The shift is non-zero only when the batch's delta-0 base record is dropped
(e.g. a tombstone) and large and positive when the batch is out of order,
turning strictly-past records into records with future timestamps. That
breaks broker timestamp validation (log.message.timestamp.after.max.ms)
and freezes downstream Kafka Streams monotonic-max KTable aggregates in
the future. Replicas compact independently, so the same offset can carry
different timestamps on different brokers.

Fix: re-base the surviving records' timestamp deltas onto the new
first_timestamp, matching Kafka's compaction re-encode
(MemoryRecordsBuilder.appendWithOffset), and compute max_timestamp from
the greatest surviving delta. record::set_timestamp_delta() keeps the
record's encoded size in sync with the re-based delta's vint width.
Applied to both compaction paths (copy_data_segment_reducer::filter and
compaction::filter::do_filter_batch). Offsets are unaffected: base_offset
is preserved as before.

Reproduced on v26.1.6 and v26.1.12 (compact topic + out-of-order
timestamps + tombstones): records produced strictly in the past read back
hundreds of days in the future after compaction. Regression tests in
reducer_test.cc and compaction_e2e_test.cc fail on the old reducer and
pass with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Aangbaeck <3142272+Aangbaeck@users.noreply.github.com>
@Aangbaeck Aangbaeck force-pushed the fix/compaction-future-timestamp-mint branch from b9090c2 to 63c750e Compare July 3, 2026 11:01
@Aangbaeck

Copy link
Copy Markdown
Author

Reworked per the review — thanks for the detailed feedback.

  • Re-based the surviving records' timestamp deltas onto the new first_timestamp (matching MemoryRecordsBuilder.appendWithOffset) instead of preserving the old one, and added record::set_timestamp_delta() to keep the encoded vint width / _size_bytes in sync.
  • Applied the same fix to compaction::filter::do_filter_batch().
  • Condensed the regression test into compaction_e2e_test.cc (dropped the separate file) and added a matching do_filter_batch test in compaction/tests/reducer_test.cc.
  • Trimmed the comments; the incident detail now lives in the commit message.
  • Rebased to a single commit — the merge commit is gone.

Verified against the base: both tests fail on the old reducer and pass with the fix (a strictly-past record reads back ~30 days in the future without it).

@WillemKauf WillemKauf 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.

Reworked per the review — thanks for the detailed feedback.

Thank you for the updates, though I'd prefer to feel like I'm talking to a human here 🙂

Changes LGTM, looking for one more added test and a slight comment update.

You'll need to run clang-format on the changes to pass our linter checks- we have a tools/format-cc.sh in the tree for this.

Comment thread src/v/model/record.h
// Re-base the timestamp delta onto a new batch first_timestamp, keeping
// _size_bytes in sync with the re-encoded vint width. Used by compaction
// when the batch's base record is dropped and first_timestamp advances.
void set_timestamp_delta(int64_t delta) {

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.

Can we get a test for this specifically? Something as simple as calling set_timestamp_delta() on an existing record and asserting that the _size_bytes is accurate. model/tests/record_batch_test.cc already has examples of this

@Aangbaeck Aangbaeck Jul 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added SetTimestampDeltaKeepsSizeBytesAccurate to record_batch_test.cc. It sets deltas that cross the vint width boundaries (63/64, -64/-65, ±14 days in ms) and re-checks the serialized size with check_serialization_size after each one.

Comment thread src/v/model/record.h Outdated

// Re-base the timestamp delta onto a new batch first_timestamp, keeping
// _size_bytes in sync with the re-encoded vint width. Used by compaction
// when the batch's base record is dropped and first_timestamp advances.

@WillemKauf WillemKauf Jul 3, 2026

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.

Used by compaction when the batch's base record is dropped and first_timestamp advances.

I don't like narrow comments that describe where a function is used, as it can quickly become out of date.

@Aangbaeck Aangbaeck Jul 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Finally, some meta - yes there is a real person behind this. 😉 But I just want to fix stuff.

Have a nice summer! 🌞

Address review: add a unit test asserting record size_bytes stays
accurate across vint width boundaries when set_timestamp_delta() is
called, reword its doc comment to describe behavior only, and run
clang-format over the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aangbaeck <3142272+Aangbaeck@users.noreply.github.com>
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