Skip to content

OA-1090: silent write failures and stale idle connections in ServerChannels#176

Open
siddharthteotia wants to merge 5 commits into
masterfrom
steotia/oa-1087-segment-load-failure-cleanup
Open

OA-1090: silent write failures and stale idle connections in ServerChannels#176
siddharthteotia wants to merge 5 commits into
masterfrom
steotia/oa-1087-segment-load-failure-cleanup

Conversation

@siddharthteotia

Copy link
Copy Markdown
Collaborator

Summary

Fixes two independent bugs in ServerChannels where dead or stale Netty connections caused queries to silently hang until timeout instead of failing fast.

Bug 1 — writeAndFlush() failure not propagated

Before (broken path):

Broker thread
    │
    ├─ connectWithoutLocking()
    │       └─ _channel.isActive() == true  ✓
    │
    ├─ writeAndFlush(requestBytes)
    │       └─ ChannelFuture listener fires:
    │               f.isSuccess()  ← NEVER CHECKED
    │               markRequestSent()  ← always called, even on failure
    │                       │
    │                       ▼
    │               AsyncQueryResponse thinks request was sent
    │               Server never received it → query hangs until timeout
    │
    └─ [query times out after 30s+ with no useful error]

After (fixed path):

Broker thread
    │
    ├─ writeAndFlush(requestBytes)
    │       └─ ChannelFuture listener fires:
    │               if (!f.isSuccess())
    │                   markServerDown()  ← all in-flight queries fail fast
    │                   return
    │               markRequestSent()     ← only on success
    │
    └─ [query fails immediately with a clear error]

Bug 2 — No idle connection eviction (silent hung TCP)

Before (broken path):

[Network partition / server frozen / firewall drop]
        │
        ▼
  TCP connection hangs — no RST, no FIN sent
        │
        ├─ Netty never fires channelInactive()
        ├─ _channel.isActive() still returns true
        └─ SO_KEEPALIVE default idle = ~2 hours on Linux
                │
                ▼
  Next query arrives for this server
        │
        ├─ connectWithoutLocking(): isActive() == true → no reconnect
        ├─ writeAndFlush(): bytes go into kernel buffer, never delivered
        └─ [query hangs until timeout; repeats for every query for up to 2h]

After (fixed path):

[Network partition / server frozen / firewall drop]
        │
        ▼
  TCP connection hangs — no RST, no FIN sent
        │
  IdleStateHandler fires after 300s of no read OR write
        │
        ▼
  ctx.close() called
        │
        ▼
  channelInactive() fires on DataTableHandler
        │
        ▼
  QueryRouter.markServerDown() → in-flight queries fail fast
        │
  Next query: isActive() == false → connectWithoutLocking() reconnects
        └─ If server still down: connect() throws → query fails immediately

Changes

File Change
NettyConfig.java Add channelIdleTimeoutSeconds (default 300s) and channelConnectTimeoutMs (default 10s)
ServerChannels.java Fix writeAndFlush listener; add IdleStateHandler; add CONNECT_TIMEOUT_MILLIS to Bootstrap
ServerChannelsTest.java Two new tests covering both fixed scenarios

Configuration (via NettyConfig):

  • channel.idle.timeout.seconds — how long a fully idle connection lives before eviction (default: 300s)
  • channel.connect.timeout.ms — max time bootstrap.connect().sync() can block (default: 10s)

Testing Done

  • testWriteFailureMarksServerDown — mocks a failed ChannelFuture; verifies QueryRouter.markServerDown() is called and markRequestSent() is NOT called
  • testIdleConnectionEviction — connects with a 1s idle timeout; verifies the channel is closed by IdleStateHandler and markServerDown() fires via channelInactive() within 5s
  • Existing testConnect tests (both NIO and native transport) still pass

Fixes: OA-1090

siddharthteotia and others added 5 commits April 10, 2026 22:48
PinotServerStreamingQueryClient cached gRPC clients in a ConcurrentHashMap
that grew monotonically — clients were never closed or removed when servers
left the cluster. This caused stale ManagedChannel connections to accumulate
over the lifetime of the broker.

The fix adds a cleanupStaleClients() method that compares cached clients
against the current enabled server instance map and closes/removes any
that are no longer needed. GrpcBrokerRequestHandler now implements
ClusterChangeHandler and is registered for INSTANCE_CONFIG changes, so
cleanup runs automatically whenever servers are added or removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove ClusterChangeHandler implementation — request handlers don't
implement that interface in this codebase. Instead, call
cleanupStaleClients() opportunistically from retryUnhealthyServer(),
which already runs periodically via the failure detector's retry thread.
This keeps the fix self-contained within GrpcBrokerRequestHandler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add try-catch around close() in cleanupStaleClients so one failing
  client doesn't prevent cleanup of remaining stale clients
- Add @VisibleForTesting getter for streamingQueryClient
- Add 13 tests covering:
  - cleanupStaleClients: single/multiple removals, port specificity,
    empty maps, idempotency, close() exception resilience
  - retryUnhealthyServer integration: stale client cleanup on retry,
    return states (UNHEALTHY/UNKNOWN/HEALTHY) for each code path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Assert return value in testRetryUnhealthyServerCleansUpStaleClients
- Add testRetryCleanupAndHealthyReturnInSameCall: verifies stale client
  cleanup AND healthy return for the retried server happen in one call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rverChannels

Two bugs in ServerChannels caused queries to hang until timeout instead of
failing fast when a server-side Netty connection was unhealthy:

1. writeAndFlush() failure not propagated: The ChannelFuture listener that
   fires after every write never checked f.isSuccess(). A write to a
   dead channel would silently complete the listener, mark the request as
   sent, and leave the query waiting forever. Fixed by calling
   QueryRouter.markServerDown() on write failure so all in-flight queries
   on that channel fail immediately.

2. No idle connection eviction: SO_KEEPALIVE relied on OS-level TCP keepalive
   (~2h default on Linux). A silently hung connection (network partition,
   frozen process) would never trigger channelInactive(), and isActive()
   would still return true. Added an IdleStateHandler (default 300s,
   configurable via NettyConfig.channelIdleTimeoutSeconds) that closes
   fully idle channels, firing channelInactive() and triggering the
   existing markServerDown() path.

Also added CONNECT_TIMEOUT_MILLIS (default 10s, configurable via
NettyConfig.channelConnectTimeoutMs) to the Bootstrap so that
bootstrap.connect().sync() cannot block indefinitely when a server is
unreachable.

Tests added:
- testWriteFailureMarksServerDown: mocks a failed ChannelFuture and verifies
  QueryRouter.markServerDown() is called and markRequestSent() is not.
- testIdleConnectionEviction: connects with a 1s idle timeout and verifies
  the channel is closed and markServerDown() is called within 5s.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@siddharthteotia siddharthteotia changed the title Fix OA-1090: silent write failures and stale idle connections in ServerChannels OA-1090: silent write failures and stale idle connections in ServerChannels Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant