storage: fix compaction minting future record timestamps from strictly-past input#30998
storage: fix compaction minting future record timestamps from strictly-past input#30998Aangbaeck wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
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_deltaduring batch filtering (rather than the first/last surviving deltas). - Preserve the original
record_batch_header.first_timestampduring compaction re-encode to keep unchanged deltas semantically valid. - Recompute
max_timestampfromfirst_timestamp + max_surviving_timestamp_deltaforCREATE_TIMEbatches.
00062db to
b5bbd44
Compare
WillemKauf
left a comment
There was a problem hiding this comment.
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.
|
Also, we'll need to apply the logic of this fix to redpanda/src/v/compaction/filter.cc Lines 76 to 101 in 29dece6 |
|
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>
b9090c2 to
63c750e
Compare
|
Reworked per the review — thanks for the detailed feedback.
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). |
There was a problem hiding this comment.
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.
| // 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) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
|
||
| // 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
When compaction drops some of a batch's records, the batch is re-encoded.
The old code advanced the new
first_timestampto the first survivingrecord but left the per-record timestamp deltas unchanged (still relative to
the batch's original
first_timestamp). On read, each record then resolvesto
new_first_timestamp + old_delta, i.e. shifted forward by the droppedbase 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 wayKafka does in
MemoryRecordsBuilder.appendWithOffset. Addedrecord::set_timestamp_delta()to keep_size_bytesin sync with there-encoded vint width. Applied to both compaction paths
(
copy_data_segment_reducer::filterandcompaction::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_timestampequals the greatest surviving timestamp. They fail on the oldreducer and pass with the fix (
compaction/tests/reducer_test.ccfor thedo_filter_batchpath,storage/tests/compaction_e2e_test.ccfor thesliding-window path).
Notes:
max_timestampis the newfirst_timestampplus the greatest survivingre-based delta, i.e. the greatest surviving absolute timestamp. (The previous
code tracked the last delta, which is wrong for out-of-order batches.)
base_offsetis preserved as before,matching the DefaultRecordBatch producer-state semantics already documented in
the reducer.