Ensure a Message's Response streamId is always set#4936
Conversation
patch by Francisco Guerrero; reviewed by TBD for CASSANDRA-21508
| ErrorMessage errorMessage = ErrorMessage.fromException(cause, handler); | ||
| // No request in scope at the channel level; a WrappedException cause carries the frame's stream id | ||
| // and overrides this 0 fallback, otherwise the channel is torn down for fatal errors. | ||
| ErrorMessage errorMessage = ErrorMessage.fromException(cause, 0, handler); |
There was a problem hiding this comment.
I would probably not use a valid streamId as a placeholder you intend to overwrite later
There was a problem hiding this comment.
Yeah, that's a very good point. I will update it
There was a problem hiding this comment.
One thing to consider here is that we won't always overwrite the stream id. I considered using the sentinel value, but this would trip the assertion during the encoding. So we'll need to keep a valid value here.
The cases where the streamId will not be overwritten are:
- ExceptionHandlers.java:81 / PreV5Handlers.java:342 : This occurs during channel negotiations (SSL/TLS handshake failures) and a stream ID is not available
- Unexpected non-wrapped pipeline throwables where no frame/stream ID exists at all
Everything else arrives in a wrapped exception and carries a real stream ID :
- CQLMessageHandler:517 : extracted.streamId() (best-effort)
- CQLMessageHandler:534 (oversized auth) : header.streamId
- CQLMessageHandler:725 (corrupt frame) : NO_REQUEST_STREAM_ID — explicit, the one true context-free CQL path
I think keeping 0 for the NO_REQUEST_STREAM_ID is reasonable because the only sources of missing stream ids are when an error occurs before a stream id is available or when a a corrupt frame does not allow us to extract the stream id.
| private static Message.Response decorateResponse(Message.Response response, Message.Request request) | ||
| { | ||
| assert response != null; | ||
| response.setStreamId(request.getStreamId()); |
There was a problem hiding this comment.
If we're setting it here, I'm still unsure why we're setting it at all callers to ErrorMessage as well...
There was a problem hiding this comment.
This method is not the only sink for responses, and that's why we still need to set it in the callers to ErrorMessage . For example, the callsites for ExceptionHandlers:81, PreV5Handlers:342, InitialConnectionHandler:134 / 163, and Dispatcher:115 do not flow through processRequest in the Dispatcher, and the stream ID would not be set. I'd prefer to have the redundancy here since it is a harmless operation.
There was a problem hiding this comment.
The first two have no streamId. I suspect that InitialConnectionHandler must have a zero streamId as well. I am not sure it is even correct to set it there - it isn't set currently, have you checked the change is valid? That is, it's possible the streamId relates to work to be done after the connection is established, and the error message is meant to be at the connection level? I don't know, but since it is a change in behaviour we should check. Either way, we close the connection immediately in the first case, so we are not at risk of this bug, and we probably should close the connection in the second case as well since it is an unexpected message on startup so the connection is in a corrupted state. I would prefer to simply do this for these two cases anyway, since it's safer without extra validation (no possible change to behaviour, and we won't be at risk of the bug).
That leaves only the Dispatcher case, and you're right that we should address this in a uniform manner. However, this suggests to me that calls to FlushItemConverter should be forced to go through a method that performs this work for us, since this is the final step before writing the reply to the wire, so captures all normal replies. Ideally, we would do something similar for the out-of-band flushes in InitialConnectionHandler and ExceptionHandlers, so that there are at most two routes to send a message. This might be annoying to have the compiler enforce, but I think it would be OK to settle for a uniform invoker of toFlushItem, especially if we are diligent about only setting streamId when we know it, so that we will fail if we hit UNSET_STREAM_ID.
Regarding redundancy: I am not fond of redundancy for its own sake - our goal should always be to make sure the code is well structured so that we must use it correctly, and so we can have confidence that all edge cases are correctly handled (and future edge cases should not be easily added). Redundancy of the kind discussed here implies we aren't confident we can do that, which suggests we need to improve our abstractions instead.
This particular redundancy also has downsides, as it has lead to the use of NO_REQUEST_STREAM_ID in one place where it seems to weaken the protection we are introducing with UNSET_STREAM_ID, because we're enforcing that it be supplied in at least one place where it doesn't seem to make semantic sense.
That said, I won't push hard on this.
| { | ||
| assert response != null; | ||
| response.setStreamId(request.getStreamId()); | ||
| response.setWarnings(ClientWarn.instance.getWarnings()); |
There was a problem hiding this comment.
I think this is too late to set warnings. The finally that resets warnings happens before this. The following test passes again if we move the warning setting back to the pre-patch location:
@Test
public void warningsAreDeliveredToClient() throws Throwable
{
try (Cluster cluster = init(Cluster.build().withNodes(1)
.withConfig(config -> config.with(GOSSIP, NETWORK, NATIVE_PROTOCOL)
.set("tombstone_warn_threshold", 0)).start()))
{
cluster.schemaChange("CREATE TABLE " + KEYSPACE + ".tbl (pk int PRIMARY KEY, v int)");
// insert a row so a tombstone is generated
cluster.coordinator(1).execute("INSERT INTO " + KEYSPACE + ".tbl (pk, v) VALUES (1, 1)", ConsistencyLevel.ONE);
cluster.coordinator(1).execute("DELETE v FROM " + KEYSPACE + ".tbl WHERE pk = 1", ConsistencyLevel.ONE);
String address = cluster.get(1).config().broadcastAddress().getAddress().getHostAddress();
int port = cluster.get(1).callOnInstance(() -> getNativeTransportPort());
try (SimpleClient client = SimpleClient.builder(address, port)
.protocolVersion(ProtocolVersion.V5)
.build().connect(false))
{
QueryMessage query = new QueryMessage("SELECT * FROM " + KEYSPACE + ".tbl WHERE pk = 1", QueryOptions.DEFAULT);
Message.Response response = client.execute(query);
Assert.assertNotNull("Expected warnings on response, got none", response.getWarnings());
Assert.assertFalse("Expected non-empty warnings list", response.getWarnings().isEmpty());
}
}
}
There was a problem hiding this comment.
Actually, I think ClientWarningsTest already catches this.
There was a problem hiding this comment.
this is now fixed after the last commit
maedhroz
left a comment
There was a problem hiding this comment.
LGTM, once we fix the bits around warning propagation (see https://github.com/apache/cassandra/pull/4936/changes#r3590923562) and CI is otherwise clean
| ErrorMessage errorMessage = ErrorMessage.fromException(cause, handler); | ||
| // No request in scope at the channel level; a WrappedException cause carries the frame's | ||
| // stream id and overrides this fallback, otherwise the channel is torn down for fatal errors. | ||
| ErrorMessage errorMessage = ErrorMessage.fromException(cause, Message.NO_REQUEST_STREAM_ID, handler); |
There was a problem hiding this comment.
If we expect this to be overridden, why are we specifying NO_REQUEST_STREAM_ID, rather than UNSET_STREAM_ID?
There was a problem hiding this comment.
There's one case where the negotiation fails during the protocol negotiation (app layer negotiation). This causes the org.apache.cassandra.transport.PreV5Handlers.ExceptionHandler#exceptionCaught code path above. And errorMessage.encode will hit the assertion in org.apache.cassandra.transport.Message#encode. If we set the stream Id to UNSET_STREAM_ID here, this would trigger the assertion error. So we'll need to do special handling for that case. This can be showcased in the org.apache.cassandra.transport.CQLConnectionTest#handleErrorDuringNegotiation test.
There was a problem hiding this comment.
Ok, I spent some time looking at this and I think we can always get a stream id except for one case. I will push a commit with these changes in a bit.
| if (responseVersion.isBeta()) | ||
| flags = Flag.add(flags, Flag.USE_BETA); | ||
|
|
||
| assert getStreamId() != UNSET_STREAM_ID : "Response streamId was never set: " + this; |
There was a problem hiding this comment.
This should not be an assertion, it should be a normal check and throw.
patch by Francisco Guerrero; reviewed by TBD for CASSANDRA-21508