Skip to content

tiered_storage: fix bad_optional_access#30303

Merged
wdberkeley merged 1 commit into
devfrom
good-options
May 28, 2026
Merged

tiered_storage: fix bad_optional_access#30303
wdberkeley merged 1 commit into
devfrom
good-options

Conversation

@wdberkeley

Copy link
Copy Markdown
Contributor

When retention advances _start_offset past all segments (the "delete everything" case) but GC fails to truncate the manifest, stale segments accumulate below _start_offset. When a new segment then arrives with a base_offset that doesn't match _start_offset exactly (there is a gap due to config batches), the fallback branch in compute_start_kafka_offset_local() used _segments.begin() and checked base_offset >= _start_offset. With stale segments present, begin() points below _start_offset, the condition is false, and the function returns nullopt. The caller (first_uploaded_offset) then calls .value() on the nullopt, throwing std::bad_optional_access through the Kafka fetch path.

Fix by using lower_bound(_start_offset) instead of begin(), which skips any stale segments and finds the first valid one at or above _start_offset.

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.1.x
  • v25.3.x
  • v25.2.x

Release Notes

Bug Fixes

  • Fixes a bug where fetches against tiered storage could fail when retention advances past all segments and new segments are added.

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

Fixes a crash in tiered-storage fetches when retention advances _start_offset past all segments but stale segment metadata remains, and later uploads introduce a gap such that no segment exists exactly at _start_offset.

Changes:

  • Update partition_manifest::compute_start_kafka_offset_local() to use _segments.lower_bound(_start_offset) so stale segments below _start_offset don’t cause a nullopt result.
  • Add a regression test that reproduces the “start beyond all segments + stale segments + new segment with gap” scenario and asserts the fetch path no longer throws.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/v/cloud_storage/partition_manifest.cc Fixes start-offset translation fallback logic to skip stale segments and avoid returning nullopt in the described retention/GC failure scenario.
src/v/cloud_storage/tests/remote_partition_test.cc Adds a regression test covering the crash scenario and validates reads proceed without std::bad_optional_access.

Comment on lines +262 to +263
auto it = _segments.lower_bound(_start_offset); it != segments_end) {
local_start_offset = it->base_offset - it->delta_offset;

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 seems reasonable to me, but also makes me wonder, should the caller be doing something different than a bad optional access? My guess it that it'll end up as a thrown exception / error case anyway, but wondering if there is a less surprising error scenario worth ironing out

It seems like this

if (config.start_offset < so && config.max_offset > so) {
mit = manifest.begin();
}
may end up calling this method. In that case, we end up scanning from manifest.begin() and I'm wondering if we need a lower_bound there too

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.

cc @Lazin for thoughts, given you authored this code and the remote_partition.cc snippet

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.

I think it doesn't solve all edge cases. It will work if the manifest is not logically empty (all segments are below _start_offset). If the lower_bound returns end there will be bad optional access anyway.

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.

Re: Andrew's concern: there's code a bit below that skips forward and handles this case.

Re: Evgeny's: You're right that if all segments are below _start_offset that this function would return nullopt. That'd be correct as there's no offset to return. The problem would be the .value call in first_uploaded_offset. However, where that's called it's guarded by an is_data_available vassert, which guarantees there is some segment at or above the start offset to be found, so the option will not be empty. I pushed the vassert into the first_uploaded_offset call and documented it as a precondition. Bottom line, I think this handles all bad optional accesses. The other outcome is a vassert trip indicating a specific bug.

@wdberkeley wdberkeley requested a review from andrwng May 28, 2026 16:46
When retention advances _start_offset past all segments (the
"delete everything" case) but GC fails to truncate the manifest,
stale segments accumulate below _start_offset. When a new segment
then arrives with a base_offset that doesn't match _start_offset
exactly (there is a gap due to config batches), the fallback branch
in compute_start_kafka_offset_local() used _segments.begin() and
checked base_offset >= _start_offset. With stale segments present,
begin() points below _start_offset, the condition is false, and the
function returns nullopt. The caller (first_uploaded_offset) then
calls .value() on the nullopt, throwing std::bad_optional_access
through the Kafka fetch path.

Fix by using lower_bound(_start_offset) instead of begin(), which
skips any stale segments and finds the first valid one at or above
_start_offset.
@vbotbuildovich

Copy link
Copy Markdown
Collaborator

Retry command for Build#85065

please wait until all jobs are finished before running the slash command

/ci-repeat 1
skip-redpanda-build
skip-units
skip-rebase
tests/rptest/tests/cluster_linking_e2e_test.py::ShadowLinkingReplicationTests.test_with_restart@{"storage_mode":"cloud"}

@vbotbuildovich

Copy link
Copy Markdown
Collaborator

CI test results

test results on build#85065
test_status test_class test_method test_arguments test_kind job_url passed reason test_history
FLAKY(FAIL) ShadowLinkingReplicationTests test_with_restart {"storage_mode": "cloud"} integration https://buildkite.com/redpanda/redpanda/builds/85065#019e6fa1-8278-4776-9afb-d4eba12159ca 34/41 Test FAILS after retries.Significant increase in flaky rate(baseline=0.0453, p0=0.0088, reject_threshold=0.0100) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=ShadowLinkingReplicationTests&test_method=test_with_restart
FLAKY(PASS) ShadowLinkingReplicationTests test_with_restart {"storage_mode": "tiered_cloud"} integration https://buildkite.com/redpanda/redpanda/builds/85065#019e6fa1-8273-4c41-a920-64d144dd3d71 10/11 Test PASSES after retries.No significant increase in flaky rate(baseline=0.0453, p0=1.0000, reject_threshold=0.0100. adj_baseline=0.1299, p1=0.2486, trust_threshold=0.5000) https://redpanda.metabaseapp.com/dashboard/87-tests?tab=142-dt-individual-test-history&test_class=ShadowLinkingReplicationTests&test_method=test_with_restart

@wdberkeley

Copy link
Copy Markdown
Contributor Author

/ci-repeat 1
skip-redpanda-build
skip-units
skip-rebase
tests/rptest/tests/cluster_linking_e2e_test.py::ShadowLinkingReplicationTests.test_with_restart@{"storage_mode":"cloud"}

@wdberkeley wdberkeley merged commit 3bd02c7 into dev May 28, 2026
19 checks passed
@wdberkeley wdberkeley deleted the good-options branch May 28, 2026 21:00
@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

/backport v25.2.x

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.

5 participants