[ 
https://issues.apache.org/jira/browse/CASSANDRA-21508?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

C. Scott Andreas updated CASSANDRA-21508:
-----------------------------------------
    Attachment: StreamIdMisrouteTest.java

> Coordinator load-shedding returns OverloadedException without setting 
> streamId, misrouting query responses
> ----------------------------------------------------------------------------------------------------------
>
>                 Key: CASSANDRA-21508
>                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21508
>             Project: Apache Cassandra
>          Issue Type: Bug
>          Components: Messaging/Client
>            Reporter: C. Scott Andreas
>            Priority: Normal
>         Attachments: StreamIdMisrouteTest.java
>
>
> When the coordinator sheds a request that has waited in the 
> Native-Transport-Requests (NTR) queue longer than 
> {{{}native_transport_timeout{}}}, it returns an {{OverloadedException}} frame 
> without setting the response stream id. The frame goes out on stream id 0 
> instead of the timed-out request's stream id. Under load this misroutes an 
> error to an unrelated in-flight request on the same connection, and can 
> escalate into silent client-side data corruption (a value from one query 
> decoded against another query's column definitions) when the native protocol 
> v5 skip-metadata optimization is in effect.
> *Severity*
> Silent read corruption. A single mis-stamped error frame can cause an 
> unrelated query's rows to be decoded against the wrong column definitions, 
> producing either a decode exception or a plausible-but-wrong value returned 
> in response to a query.
> *Root Cause*
> {{{}Dispatcher.processRequest{}}}, {{{}Dispatcher.java:366-369{}}}:
> if (queueTime > 
> DatabaseDescriptor.getNativeTransportTimeout(TimeUnit.NANOSECONDS))
> {    ClientMetrics.instance.markTimedOutBeforeProcessing();    return 
> ErrorMessage.fromException(new OverloadedException("Query timed out before it 
> could start"));
> }   // <-- no setStreamId(request.getStreamId())
>  
> This is the only response-producing exit in {{Dispatcher}} that does not set 
> the stream id. The other exits set it explicitly 
> ({{{}Dispatcher.java:113{}}}, {{{}:423{}}}, {{{}:448{}}}).
>  * {{ErrorMessage.fromException(e, null)}} initializes {{streamId = 0}} 
> ({{{}ErrorMessage.java:415{}}}).
>  * {{OverloadedException}} extends {{RequestExecutionException}} (not 
> {{{}WrappedException{}}}), so no stream id is recovered from the throwable.
>  * {{Message.streamId}} defaults to 0.
>  * The outer {{processRequest}} ({{{}Dispatcher.java:433-437{}}}) returns 
> this response via the normal (non-exceptional) path, so the catch block that 
> would have called {{setStreamId}} ({{{}:448{}}}) never runs.
>  * The frame is flushed and {{Message.encode}} uses {{{}getStreamId() == 
> 0{}}}.
> Result: the OVERLOADED error reaches the client on stream id 0, not on the 
> stream id of the request that actually timed out.
> {{OverloadedException}} encodes as a normal, non-fatal error, so the client 
> demultiplexes it by stream id (the channel is not torn down).{*}{*}
>  
> *Mechanism for triggering client-side corruption:*
> Two independent requests are involved:
>  * {*}Request A{*}: the one that sat in the NTR queue past the deadline. The 
> server sheds it and emits the OVERLOADED error, mis-stamped with stream id 0.
>  * {*}Request B{*}: a different, healthy request that the client currently 
> has on stream id 0. The server is still processing it normally.
> On a busy connection stream id 0 is almost always in use, because the 
> DataStax java-driver allocates the lowest free id first 
> ({{{}StreamIdGenerator.acquire(){}}} uses {{{}ids.nextClearBit(0){}}}). The 
> sequence:
>  # The client receives OVERLOADED on stream id 0 and applies it to request B. 
> B is completed (failed or retried) and the client performs a normal, reusable 
> release of stream id 0. This differs from a client-side timeout, which would 
> orphan the id and keep it reserved; here the client is led to believe B 
> genuinely finished.
>  # The client reuses stream id 0 for a new request C (a different prepared 
> statement, with its own cached column definitions).
>  # Request B's real rows response arrives on stream id 0 and is delivered to 
> C.
>  # Under skip-metadata the rows frame carries no column metadata 
> ({{{}ResultSet.java{}}}: the column count is written unconditionally, but the 
> name/type loop is skipped under the no-metadata flag), so the driver decodes 
> B's row bytes positionally against C's cached column definitions.
> When B's and C's column orders differ, each value lands one or more columns 
> away from where it belongs. Type-incompatible positions throw in the codec 
> (for example {{Invalid boolean value, expecting 1 byte but got 8}} when an 
> 8-byte value lands in a boolean slot, or a {{MalformedInputException}} when 
> non-UTF-8 bytes land in a text slot). Type-compatible positions decode 
> cleanly and return the wrong value, which the application can then persist.
> The stream id 0 misroute happens at the transport layer, below result 
> metadata. A schema or metadata change is not required to trigger it.
> Diagram:
>  
> {{{{        Client (java-driver)                        Server (coordinator, 
> overloaded)
>         --------------------                        
> --------------------------------
>   B = healthy query              --- stream 0 --->  B queued, still 
> processing ...
>   A = other query                --- stream 1 --->  A sits in NTR queue too 
> long
>                                                     -> load-sheds A: sends an 
> overload
>                                                        error but does not set 
> stream id
>                                  <-- error ---------    => stamped stream id 
> 0   [defect]
>                                      (stream 0)
>   inFlight[0] == B  => error delivered to B,
>   B completed, stream 0 released (normal reuse,
>   not an orphan)
>   C = new query                  --- reuse 0 --->   B's real read finally 
> finishes ...
>                                  <-- rows -----------   B's rows sent on 
> stream 0 (correct
>                                      (stream 0)          from the server's 
> view)
>   inFlight[0] == C  => B's rows decoded against
>   C's column definitions (skip-metadata: no
>   column info in the frame)
>         => a value from B lands in the wrong column of C}}}}
>  
> *Steps to Reproduce*
> Extreme load produces this naturally (NTR queue backlog on a saturated 
> coordinator, correlating with the OVERLOADED errors and a client-side timeout 
> storm). It can be reproduced deterministically with the in-JVM dtest below:
>  * Start a 1-node cluster with {{native_transport_max_threads=1}} and 
> {{{}native_transport_timeout=500ms{}}}.
>  * Slow down SELECT execution (a ByteBuddy interceptor on 
> {{{}SelectStatement.execute{}}}, as in {{{}OverloadTest.SlowSelect{}}}) so a 
> request queued behind a running one crosses the deadline.
>  * On one {{SimpleClient}} connection, saturate the single NTR worker and 
> pile several requests behind it. On a second connection, send a request on a 
> non-zero stream id (42) that queues past the deadline and is shed.
>  * Observe that the response is an {{OverloadedException}} and that its 
> stream id is 0, not 42.
> Run:
> {{ant test-jvm-dtest-some 
> -Dtest.name=org.apache.cassandra.distributed.test.StreamIdMisrouteTest}}
>  
> *Expected*
> The load-shed OVERLOADED error carries the stream id of the request that 
> timed out, so the client attributes it to the correct request and never frees 
> a stream id that still has an in-flight response.
>  
> *Actual*
> The OVERLOADED error carries stream id 0. On a busy connection it is 
> delivered to an unrelated in-flight request, whose stream id is then freed 
> and reused, so a later legitimate rows response is decoded against the wrong 
> query's column definitions.
>  
> *Simplest Fix*
> Set the stream id on the load-shed path, matching every other 
> response-producing exit in {{{}Dispatcher{}}}:
> if (queueTime > 
> DatabaseDescriptor.getNativeTransportTimeout(TimeUnit.NANOSECONDS))
> {    ClientMetrics.instance.markTimedOutBeforeProcessing();    
> Message.Response response =        ErrorMessage.fromException(new 
> OverloadedException("Query timed out before it could start"));    
> response.setStreamId(request.getStreamId());    return response;
> }
>  
> With this change the shed error is delivered to the request that actually 
> timed out, and no unrelated stream id is freed early.
>  
> This response path is overly brittle, though. As it's designed today, each 
> return site is responsible for setting the streamId on the response, which is 
> easy to miss.
>  
> We should consider: * {*}Cassandra{*}{*}:{*} Setting the streamId centrally 
> in the Dispatcher instance's processRequest method to ensure all paths are 
> guarded (suggestion from Benedict).
>  * *Cassandra:* Initialize the streamId to an invalid sentinel value and 
> assert that it is properly set before returning to the client (suggestion 
> from Benedict).
>  * *Java* *Driver:* Altering the driver to stop using streamId=0 to avoid 
> this behavior from older/unpatched server versions.
>  * *Java Driver:* Validate value count versus column definitions count on the 
> skip-metadata decode path so a rows/definitions mismatch fails loudly instead 
> of silently mis-decoding.
>  * *Protocol:* Sending a digest of schema and/or metadata with responses for 
> clients to validate, ensuring that the version of the schema rows are decoded 
> against matches between the client and server.
>  * *Other drivers:* Review behavior and apply changes as appropriate.
>  
> *Reproduction Test*
> The dtest below asserts the shed error comes back on stream id 0. Applying 
> the fix above changes the observed stream id from 0 to the request's own id 
> (42), so the test fails when the bug is fixed and passes while it is present, 
> which confirms it exercises the real path.
> Verified locally:
>  * Unmodified source: {{tests=2 errors=0 failures=0}} (both pass).
>  * With the fix applied: {{loadShedErrorIsStampedWithStreamIdZero}} fails 
> with {{{}expected:<0> but was:<42>{}}}; the decode test still passes.
> Place at 
> {{test/distributed/org/apache/cassandra/distributed/test/StreamIdMisrouteTest.java}}
> [dtest attached].



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to