@@ -1209,3 +1209,113 @@ FIXTURE_TEST(fetch_response_bytes_eq_units, redpanda_thread_fixture) {
12091209 BOOST_REQUIRE (octx.response_size > 0 );
12101210 BOOST_REQUIRE (octx.response_size == octx.total_response_memory_units ());
12111211}
1212+
1213+ // Regression test for CORE-14617: When a fetch is retried internally (due to
1214+ // min_bytes not being satisfied), partitions with changed metadata (like
1215+ // log_start_offset) must still be included in the final response.
1216+ FIXTURE_TEST (
1217+ fetch_session_propagates_log_start_offset, redpanda_thread_fixture) {
1218+ model::topic topic (" foo" );
1219+ model::partition_id pid (0 );
1220+ auto ntp = make_default_ntp (topic, pid);
1221+
1222+ wait_for_controller_leadership ().get ();
1223+ add_topic (model::topic_namespace_view (ntp)).get ();
1224+ wait_for_partition_offset (ntp, model::offset (0 )).get ();
1225+
1226+ // Produce some data
1227+ auto shard = app.shard_table .local ().shard_for (ntp);
1228+ app.partition_manager
1229+ .invoke_on (
1230+ *shard,
1231+ [ntp](cluster::partition_manager& mgr) {
1232+ return model::test::make_random_batches (model::offset (0 ), 20 )
1233+ .then ([ntp, &mgr](auto batches) {
1234+ auto partition = mgr.get (ntp);
1235+ return partition->raft ()->replicate (
1236+ chunked_vector<model::record_batch>(
1237+ std::from_range,
1238+ std::move (batches) | std::views::as_rvalue),
1239+ raft::replicate_options (
1240+ raft::consistency_level::quorum_ack));
1241+ });
1242+ })
1243+ .get ();
1244+
1245+ auto client = make_kafka_client ().get ();
1246+ client.connect ().get ();
1247+
1248+ // Full fetch to establish session (session_epoch=0, invalid session_id)
1249+ kafka::fetch_request req1;
1250+ req1.data .max_bytes = std::numeric_limits<int32_t >::max ();
1251+ req1.data .min_bytes = 1 ;
1252+ req1.data .max_wait_ms = 1000ms;
1253+ req1.data .session_id = kafka::invalid_fetch_session_id;
1254+ req1.data .session_epoch = kafka::initial_fetch_session_epoch;
1255+ req1.data .topics .emplace_back (
1256+ kafka::fetch_topic{
1257+ .topic = topic,
1258+ .partitions = {{
1259+ .partition = pid,
1260+ .fetch_offset = model::offset (5 ),
1261+ }},
1262+ });
1263+
1264+ auto resp1 = client.dispatch (std::move (req1), kafka::api_version (12 )).get ();
1265+ BOOST_REQUIRE_EQUAL (resp1.data .responses .size (), 1 );
1266+ BOOST_REQUIRE_EQUAL (
1267+ resp1.data .responses [0 ].partitions [0 ].error_code ,
1268+ kafka::error_code::none);
1269+ BOOST_REQUIRE_NE (resp1.data .session_id , kafka::invalid_fetch_session_id);
1270+
1271+ auto session_id = resp1.data .session_id ;
1272+ auto initial_log_start
1273+ = resp1.data .responses [0 ].partitions [0 ].log_start_offset ;
1274+
1275+ // Prefix truncate to change log_start_offset
1276+ auto trunc_err = app.partition_manager
1277+ .invoke_on (
1278+ *shard,
1279+ [ntp](cluster::partition_manager& mgr) {
1280+ auto partition = mgr.get (ntp);
1281+ auto k_trunc_offset = kafka::offset (5 );
1282+ auto rp_trunc_offset
1283+ = partition->log ()->to_log_offset (
1284+ model::offset (k_trunc_offset));
1285+ return partition->prefix_truncate (
1286+ rp_trunc_offset,
1287+ k_trunc_offset,
1288+ ss::lowres_clock::time_point::max ());
1289+ })
1290+ .get ();
1291+ BOOST_REQUIRE (!trunc_err);
1292+
1293+ // Incremental fetch with min_bytes=1 - will retry internally waiting for
1294+ // data, but should still include the partition due to log_start_offset
1295+ // change even if no new data arrives during the retry window.
1296+ kafka::fetch_request req2;
1297+ req2.data .max_bytes = std::numeric_limits<int32_t >::max ();
1298+ req2.data .min_bytes = 1 ;
1299+ req2.data .max_wait_ms = 1000ms;
1300+ req2.data .session_id = session_id;
1301+ req2.data .session_epoch = kafka::fetch_session_epoch (1 );
1302+ req2.data .topics .emplace_back (
1303+ kafka::fetch_topic{
1304+ .topic = topic,
1305+ .partitions = {{
1306+ .partition = pid,
1307+ .fetch_offset = model::offset (20 ),
1308+ }},
1309+ });
1310+
1311+ auto resp2 = client.dispatch (std::move (req2), kafka::api_version (12 )).get ();
1312+ client.stop ().then ([&client] { client.shutdown (); }).get ();
1313+
1314+ // The partition must be included even though no new data arrived, because
1315+ // log_start_offset changed. This is the regression test for CORE-14617.
1316+ BOOST_REQUIRE_EQUAL (resp2.data .responses .size (), 1 );
1317+ BOOST_REQUIRE_EQUAL (resp2.data .responses [0 ].partitions .size (), 1 );
1318+ auto new_log_start = resp2.data .responses [0 ].partitions [0 ].log_start_offset ;
1319+ BOOST_REQUIRE_GT (new_log_start, initial_log_start);
1320+ BOOST_REQUIRE_EQUAL (new_log_start, model::offset (5 ));
1321+ }
0 commit comments