Skip to content

TopicRetryableStream async initialization - #726

Open
alex268 wants to merge 3 commits into
ydb-platform:masterfrom
alex268:topic-async-write-stream-factory
Open

alex268 wants to merge 3 commits into
ydb-platform:masterfrom
alex268:topic-async-write-stream-factory

Conversation

@alex268

@alex268 alex268 commented Sep 23, 2026

Copy link
Copy Markdown
Member

No description provided.

Igor Melnichenko and others added 3 commits September 17, 2026 18:33
TopicRetryableStream.start() is called from the shared transport scheduler on
every reconnect. With directWrite enabled, WriteStreamDirectFactory resolved
the target partition and its location synchronously inside createNewStream:
lookupPartitionId() joined a probe stream future (1 min deadline) and
lookupLocation() joined describeTopic() (1 min deadline). Each reconnect of an
unresponsive destination could therefore occupy a scheduler thread for up to two
minutes. The shared scheduler is sized max(cores / 2, 2) and is also used by
discovery, session pools, retry contexts and operation tray, so a handful of
stalled writers could stall the whole transport: session acquire timeouts stop
firing and discovery ticks stop running.

Make createNewStream() return CompletableFuture and compose the partition and
location lookups instead of joining them, so no shared scheduler thread is held
while a stream is being created.

Since stream creation is now asynchronous, close() may happen while it is in
progress. TopicRetryableStream handles that by re-checking isClosed after
publishing the new stream: close() sets the volatile flag before clearing the
stream reference, so a creation that wins the race always observes the flag and
drops the stream without starting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.21739% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.47%. Comparing base (f3fcaec) to head (380415c).

Files with missing lines Patch % Lines
...java/tech/ydb/topic/impl/TopicRetryableStream.java 87.23% 2 Missing and 4 partials ⚠️
...main/java/tech/ydb/topic/read/impl/ReaderImpl.java 66.66% 0 Missing and 2 partials ⚠️
...ydb/topic/write/impl/WriteStreamDirectFactory.java 97.29% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #726      +/-   ##
============================================
- Coverage     74.54%   74.47%   -0.08%     
- Complexity     3637     3641       +4     
============================================
  Files           392      391       -1     
  Lines         16493    16518      +25     
  Branches       1738     1741       +3     
============================================
+ Hits          12294    12301       +7     
- Misses         3591     3597       +6     
- Partials        608      620      +12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

String streamId = realStreamId.getAndSet(null);
if (streamId != null) {
logger.warn("[{}] failed by application-side error {}", streamId, status);
S local = realStream;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Major
Confidence: Medium

fail() (and close()) now tear down the stream in two independent steps: realStreamId.getAndSet(null) followed by a separate volatile read of realStream. If a pending createNewStream future completes in between (possible when start() was called twice, or a user start() races a retry reconnect), tryStartStream wins the CAS(null, id2) and installs a new stream, and this method then reads local = realStream and closes the new stream — while the stream this call intended to stop is abandoned unmanaged: its own stop callback loses the realStreamId.compareAndSet(streamID, null) in startStream, so its termination is never processed and it keeps delivering messages (e.g. duplicate data for a reader) until the server closes it. The old code was immune because it swapped the stream with a single atomic realStream.getAndSet(null).

Consider keeping the id and the stream in one atomic reference (e.g. AtomicReference of an (id, stream) entry) so they are captured and cleared together, and adding a test for fail()/close() interleaved with an in-flight creation.

}

private void tryStartStream(String streamID, Result<S> result, Throwable th) {
if (isClosed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Major
Confidence: High

Both early-return paths of tryStartStream (this isClosed skip and the "double start" CAS failure below) discard an already-created stream without ever starting or closing it, leaking the underlying gRPC call that the factories create eagerly (rpc.writeSession(...) in WriteStreamFactory/WriteStreamDirectFactory, rpc.readSession(...) in ReaderImpl). The isClosed branch is newly reachable and easy to hit: close() while a stream is being created — e.g. writer.shutdown() during a direct-write initial connect or reconnect, where the probe stream plus describeTopic (1-minute deadline) can be in flight for a long time — guarantees the leak. closeWhileStreamIsCreatingTest codifies that the stream "must not be started", but never asserts it is closed either.

Suggested fix: on both skip paths, close the created stream when the result is successful, e.g. if (result != null && result.isSuccess()) result.getValue().close(); — and consider propagating cancellation into the creation future so the probe/describe work is also abandoned on close().


String streamID = debugId + '.' + streamCount.incrementAndGet();
S stream = createNewStream(streamID);
createNewStream(streamID).whenComplete((result, th) -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Minor
Confidence: Low

Anything thrown inside tryStartStream — including exceptions from user callbacks reached via onStreamStoponRetry/onClose — is captured by the future returned from whenComplete, which nobody observes, so retry/stop handling silently dies mid-flight. Previously the first start() ran this logic on the caller thread, so a synchronous failure at least surfaced to the caller.

Consider wrapping the callback body in try/catch with a logger.error, so a failure here is at least visible in the logs.

}

if (result == null) {
logger.warn("[{}] cannot create stream with exeption", streamID, th);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Nit
Confidence: High

Typo in the log message: "exeption" → "exception".

* @param id identifier of the new stream for logging
* @return future with the new stream
*/
public CompletableFuture<Result<WriteSession.Stream>> createNewStream(String id) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Minor
Confidence: High

This is a source-incompatible signature change on a public extension point: WriteStreamFactory is user-extensible (a custom instance can be passed to the writer constructor), so custom subclasses overriding createNewStream will stop compiling. The same applies to subclasses of TopicRetryableStream whose onRetry/onClose now receive a @Nullable stream. Since the class lives in an impl package this may be acceptable, but it deserves a mention in the release notes; otherwise consider keeping a delegating overload for the old signature.

));
}

private WriteSession.Stream buildDirectStream(String id, long partitionId, YdbTopic.PartitionLocation location) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: Nit
Confidence: High

The parameter partitionId shadows the partitionId field of WriteStreamFactory. It is used consistently inside this method, but the shadowing makes it easy to confuse the resolved partition id with the configured one — consider renaming the parameter (e.g. resolvedPartitionId).

@robot-vibe-db

robot-vibe-db Bot commented Sep 23, 2026

Copy link
Copy Markdown

AI Review Summary

Verdict: ✅ No critical issues found

Critical issues

No critical issues found.

Other findings

  • Major | Medium: fail()/close() split the teardown into realStreamId.getAndSet(null) plus a separate read of realStream; a concurrently completing createNewStream future in between makes them close the newly-installed stream and abandon the previously active one unmanaged (its termination is never processed). The old code swapped the stream in a single atomic step — topic/src/main/java/tech/ydb/topic/impl/TopicRetryableStream.java:140
  • Major | High: tryStartStream discards an already-created stream on both early-return paths (isClosed while creation is in flight, "double start" CAS failure) without closing it, leaking the eagerly-created gRPC call; reachable e.g. via writer.shutdown() during a direct-write (re)connect — topic/src/main/java/tech/ydb/topic/impl/TopicRetryableStream.java:78
  • Minor | Low: exceptions thrown inside tryStartStream (including user callbacks reached via onRetry/onClose) are swallowed by the unobserved future returned from whenComplete, silently killing retry/stop handling — topic/src/main/java/tech/ydb/topic/impl/TopicRetryableStream.java:72
  • Minor | High: source-incompatible signature change on the public, user-extensible WriteStreamFactory.createNewStream (and @Nullable stream params on TopicRetryableStream.onRetry/onClose); custom subclasses stop compiling — topic/src/main/java/tech/ydb/topic/write/impl/WriteStreamFactory.java:63
  • Nit | High: typo "exeption" in the creation-failure log message — topic/src/main/java/tech/ydb/topic/impl/TopicRetryableStream.java:98
  • Nit | High: buildDirectStream parameter partitionId shadows the partitionId field of WriteStreamFactorytopic/src/main/java/tech/ydb/topic/write/impl/WriteStreamDirectFactory.java:50

The core change itself (non-blocking stream creation, Result-based factory contract, @Nullable handling in ReaderImpl) looks sound, the new async/close/creation-failure scenarios are covered by tests, and the full topic module test suite passes.


This review was generated automatically. Critical issues require attention; other findings are advisory.
If this comment was useful, please give it a 👍 — it helps us improve the review bot.

@robot-vibe-db

robot-vibe-db Bot commented Sep 23, 2026

Copy link
Copy Markdown

Full analysis log

Analysis performed by claude, z-ai/glm-5.3-flash.

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.

1 participant