Skip to content

Commit a2f391f

Browse files
authored
Merge pull request #834 from ydb-platform/fix-tx-reader-stale-offset-commit
Fix transactional topic reader committing stale offsets after reconnect
2 parents 852a3a0 + fe00ecb commit a2f391f

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

tests/topics/test_topic_transactions.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,49 @@ async def callee(tx: ydb.aio.QueryTxContext):
138138
msg = await wait_for(reader.receive_message(), DEFAULT_TIMEOUT)
139139
assert msg.data.decode() == "123"
140140

141+
async def test_tx_commit_after_reconnect_does_not_commit_stale_offsets(
142+
self, driver: ydb.aio.Driver, topic_with_messages, topic_consumer
143+
):
144+
async with driver.topic_client.reader(topic_with_messages, topic_consumer) as reader:
145+
async with ydb.aio.QuerySessionPool(driver) as pool:
146+
async with pool.checkout() as session:
147+
tx = session.transaction()
148+
await tx.begin()
149+
150+
batch = await wait_for(reader.receive_batch_with_tx(tx, max_messages=1), DEFAULT_TIMEOUT)
151+
assert batch.messages[0].data.decode() == "123"
152+
153+
reconnector = reader._reconnector
154+
old_stream = reconnector._stream_reader
155+
156+
with mock.patch.object(
157+
reconnector,
158+
"_do_commit_batches_with_tx_call",
159+
wraps=reconnector._do_commit_batches_with_tx_call,
160+
) as update_offsets_call:
161+
# Force a reconnect between receive_batch_with_tx() and commit, so the
162+
# batch belongs to a partition session that no longer exists.
163+
old_stream._set_first_error(ydb.issues.ConnectionLost("forced reconnect"))
164+
for _ in range(100):
165+
await asyncio.sleep(0.05)
166+
current = reconnector._stream_reader
167+
if current is not None and current is not old_stream and current._started:
168+
break
169+
assert reconnector._stream_reader is not old_stream
170+
171+
# Committing the stale batch must fail loudly instead of silently
172+
# sending a gapped UpdateOffsetsInTransaction for the dead session.
173+
with pytest.raises(ydb.Error):
174+
await tx.commit()
175+
176+
update_offsets_call.assert_not_called()
177+
178+
assert len(reader._reconnector._tx_to_batches_map) == 0
179+
180+
# The consumer offset must not have advanced: the message is read again.
181+
msg = await wait_for(reader.receive_message(), DEFAULT_TIMEOUT)
182+
assert msg.data.decode() == "123"
183+
141184

142185
class TestTopicTransactionalReaderSync:
143186
def test_commit(self, driver_sync: ydb.Driver, topic_with_messages, topic_consumer):

ydb/_topic_reader/topic_reader_asyncio.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,44 @@ def _init_tx(self, tx: "BaseQueryTxContext"):
323323
tx._add_callback(TxEvent.AFTER_COMMIT, self._handle_after_tx_commit, self._loop)
324324
tx._add_callback(TxEvent.AFTER_ROLLBACK, self._handle_after_tx_rollback, self._loop)
325325

326+
def _batch_partition_session_expired(self, batch: datatypes.PublicBatch) -> bool:
327+
# A batch is expired if the reader reconnected after it was received: its partition
328+
# session no longer belongs to the current stream. Mirrors the guard in
329+
# ReaderStream.commit() for the non-transactional commit path.
330+
stream = self._stream_reader
331+
partition_session = batch._partition_session
332+
return (
333+
stream is None
334+
or partition_session.reader_stream_id != stream._id
335+
or partition_session.id not in stream._partition_sessions
336+
)
337+
326338
async def _commit_batches_with_tx(self, tx: "BaseQueryTxContext"):
327339
tx_id = tx.tx_id
328340
if tx_id is None:
329341
raise TopicReaderError("Transaction ID is None")
342+
343+
batches = self._tx_to_batches_map[tx_id]
344+
345+
if any(self._batch_partition_session_expired(batch) for batch in batches):
346+
# The reader reconnected between receive_batch_with_tx() and tx.commit(), so
347+
# these offsets belong to a partition session that no longer exists. Committing
348+
# them would send a stale/gapped range (server "Gap", issue_code 2011) while the
349+
# client believes the commit succeeded. Fail the tx instead (retriable) without
350+
# sending the request; the AFTER_COMMIT handler then reconnects to reset the
351+
# read-ahead state, and the pool re-reads from the committed offset.
352+
err = issues.ClientInternalError(
353+
"Topic reader partition session expired before tx commit; "
354+
"offsets were not committed, the transaction will be retried"
355+
)
356+
tx._set_external_error(err)
357+
del self._tx_to_batches_map[tx_id]
358+
return
359+
330360
grouped_batches: Dict[str, Dict[int, typing.List[datatypes.PublicBatch]]] = defaultdict(
331361
lambda: defaultdict(list)
332362
)
333-
for batch in self._tx_to_batches_map[tx_id]:
363+
for batch in batches:
334364
grouped_batches[batch._partition_session.topic_path][batch._partition_session.partition_id].append(batch)
335365

336366
consumer = self._settings.consumer

0 commit comments

Comments
 (0)