Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement client-side timeouts #329

Closed
wants to merge 11 commits into from
Closed

Implement client-side timeouts #329

wants to merge 11 commits into from

Conversation

psarna
Copy link
Contributor

@psarna psarna commented Nov 4, 2021

Pre-review checklist

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I added appropriate Fixes: annotations to PR description.

This pull request introduces client-side timeouts and a management layer for orphaned stream ids. Orphaned stream ids are the ones for which the client-side timeout already fired, but no response came from the server yet. In such scenario, a stream id cannot be released yet, because the response associated with the same id may still be received later. Instead, orphaned stream ids are tracked per connection, and once a threshold is reached, the connection is killed.

Fixes #304
Fixes #305

@psarna psarna force-pushed the client_side_timeouts branch 3 times, most recently from 19eff12 to b287ef2 Compare November 5, 2021 08:10
@psarna
Copy link
Contributor Author

psarna commented Nov 5, 2021

Tested manually by setting up a program with very low client timeout and observing the orphan count rise up to the threshold.

@psarna psarna requested review from piodul and cvybhu November 8, 2021 11:35
@psarna psarna marked this pull request as ready for review November 8, 2021 11:36
Comment on lines 66 to 71
AllocStreamId {
stream_id_sender: oneshot::Sender<i16>,
response_handler: ResponseHandler,
},
// Send a request to the server
Request {
stream_id: i16,
serialized_request: SerializedRequest,
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think those should be a single message. Consider a situation in which the future returned from send_request sends AllocStreamId, then is dropped while waiting for the stream ID and does not send a Request message. The stream ID will not be freed in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, right, makes sense to reduce the back-and-forth anyway. And all it takes is to add the stream_id_sender to Request. Will do in v2

@psarna psarna force-pushed the client_side_timeouts branch from 3cfb15a to 6b4051f Compare November 9, 2021 10:13
@psarna
Copy link
Contributor Author

psarna commented Nov 9, 2021

v2:

  • merged AllocStreamId and Request into a single task -- there was no point in splitting it in two in the first place; stream id is simply returned via a separate oneshot channel
  • retested

@psarna psarna mentioned this pull request Nov 9, 2021
@@ -61,9 +61,17 @@ pub struct Connection {

Copy link
Collaborator

Choose a reason for hiding this comment

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

Regarding the commit message: I believe there are 32k streams available on a connection, not 16k.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, but negative ids are reserved for events iirc, so users can de facto use 16k

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh wait, the pool is 64k and 32k are for the user, you're right

Comment on lines 777 to 911
if let Some(id) = hmap.allocate(response_handler) {
stream_id_sender
.send(id)
.map_err(|_| QueryError::UnableToAllocStreamId)?;
id
} else {
error!("Unable to allocate stream id");
return Err(QueryError::UnableToAllocStreamId);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

If an error occurs here, it will be returned from the writer function, which will close the whole connection and will propagate this error to everybody who waits for a response on this connection. Previously, if we couldn't allocate a stream ID for a request, only the one who waits for this request got an error (because the Sender half of the response channel was dropped and the Receiver gets an error). Is that an intended change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it was intended, but we can discuss whether it's too harsh or not

"Too many orphaned stream ids: {}, dropping connection",
hmap.orphan_count()
);
return Err(QueryError::TooManyOrphanedStreamIds(hmap.orphan_count()));
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm a bit worried that immediately closing a connection in case of too many orphaned IDs may unnecessarily cause availability issues. What if all connections start timing out requests at the same rate and we decide to close all of them for that reason at the same time?

Comment on lines 1067 to 1068
// Spontaneous errors are expected when running with a client timeout set to 0 seconds.
// If they happen, the test case is assumed to be correct
Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel that it would be nice for client-side timeouts to be configurable per-query, not per-session. Latency requirements may be different for different queries - in particular, setting the timeout too low may cause some of the internal queries not to work correctly, e.g. topology refresh. Also, if we did that, this test could be written differently so that it only expects failure from the query with explicitly set client-side timeout.

We could have a separate internal timeout for queries (maybe the connection timeout could be used?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, I'll add a way to define a client timeout per query

req.set_stream(stream_id);
write_half.write_all(req.get_data()).await?;
Task::Orphan { stream_id } => {
let mut hmap = handler_map.try_lock().unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Here, we probably can remove the entry from handler_map corresponding to the orphaned stream ID.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why? The id is orphaned, but a response to it can still arrive, so once it does, it will remove the entry as well. And if it doesn't and eventually we hit max orphan count, the connection dies anway. Or do you mean that only as an optimization?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I mainly meant it as an optimization - the memory used by the entry can be freed at this point because the contents of the entry won't be used (it's a Sender of a oneshot channel which won't be read from).

@psarna psarna force-pushed the client_side_timeouts branch from 6b4051f to 8a32ff9 Compare November 12, 2021 09:56
The configuration option will be used to apply client-side timeouts
for requests.
which indicates, that the query timed out client-side,
but it can still be processed by Scylla for all we know.
Client-side timeout is now applied to requests - if a request takes
longer to receive a response than the specified timeout, it will be
reported as an error.
The error fires when a threshold of orphaned stream ids,
i.e. the ones on which the client stopped waiting, but no response
came from the server, is reached.
@psarna psarna force-pushed the client_side_timeouts branch from 8a32ff9 to b993a7c Compare November 12, 2021 10:26
@psarna
Copy link
Contributor Author

psarna commented Nov 12, 2021

v3:

  • added a way to specify per-query client timeout
  • added docs

@psarna
Copy link
Contributor Author

psarna commented Nov 12, 2021

The fact that the whole connection is killed now in the event of not enough stream ids is still not addressed, but after some consideration I decided to keep the original semantics. We should have better handling of no stream ids anyway, but there's no point in severing a connection which could still have ongoing requests just because somebody pushed one too many new requests in there.

When a client-side timeout fires, the driver stops waiting
for a response for a particular stream id. We cannot however
release this stream id to the pool, because the response may
still arrive late. Such abandonded stream ids are tracked separately,
and once a connection has too many of them, it breaks
and a new connection should be established instead.
The current threshold is hardcoded to 1024 (out of ~32k stream ids
total).
The number of acceptable orphaned stream ids is now configurable.
After the specified limit is hit, the connection will be killed.
It's now possible to specify the client-side timeout independently
for each query.
The test case checks if client-side timeout fires when
its time passes.
 1. a typo
 2. a statement about unlimited concurrency was a bit misleading
A short description of client-side timeouts is added to the docs.
@psarna psarna force-pushed the client_side_timeouts branch from b993a7c to d453980 Compare November 12, 2021 10:44
@psarna
Copy link
Contributor Author

psarna commented Nov 12, 2021

v4:

  • restored the original behavior of not killing the connection when a stream id could not be allocated; instead, only the offending request will get an error

@psarna psarna requested a review from havaker November 16, 2021 16:14
@psarna
Copy link
Contributor Author

psarna commented Nov 16, 2021

review ping

.map_err(|e| {
QueryError::ClientTimeout(format!(
.map_err(|_| QueryError::UnableToAllocStreamId)?;
let received = match tokio::time::timeout(self.config.client_timeout, receiver).await {
Copy link
Contributor

Choose a reason for hiding this comment

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

Library user can decide to stop polling future returned by Session::query* method, causing this line to be last line executed. In this case no Task::Orphan message will be send via self.submit_channel.

I think that speculative execution can also cancel futures, so the problem persists even without user intentionally canceling futures.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's true, but it's also no worse than what we have now - the connection would eventually go out of stream ids and die. Users should be encouraged to use our timeout instead of tokio timeout. Do you have any suggestions on how to approach this issue?

Copy link
Collaborator

Choose a reason for hiding this comment

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

One idea that comes to my mind would be to make the router responsible for detecting timeouts and marking stream IDs as orphaned. It would keep a BTreeMap-based queue of stream IDs ordered by their deadlines.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm more for a different solution.
The problem in current solution is that we are not able to deliver notification about orphanage to the router, due to future cancellation.

To ensure, that some action is run in an async function, even when a function's future was dropped, one can use a destructor of an object living in the body of this function. I propose introducing such object to the send_request function, to use it for notifying router about orphanage.

Due to destructors not being async, notifying via bounded channel as it was done now is not an option. Luckily, unbounded tokio channels do not require awaiting to send some value, so they can be used in rust's drop implementations.

struct OrphanhoodNotifier {
    disabled: bool, // initialized to false
    stream_id: i16,
    sender: UnboundedSender<i16>,
}

impl OrphanhoodNotifier {
    fn disable(mut self) {
        self.disabled = true;
    }
}

impl Drop for OrphanhoodNotifier {
    fn drop(&mut self) {
        if (!self.disabled) {
            self.sender.send(self.stream_id);
        }
    }
}

I imagine, that the OrphanhoodNotifier would be sent instead of raw stream_id using stream_id_sender (as this would protect us from not notifying about orphanage, when send_request is canceled before receiving stream_id from stream_id_receiver but after it submitted Task::Request). After receival of query response, orphan notification would be disabled by calling .disable() method of OrphanhoodNotifier.

Such design would also require changes in router's way of allocating stream_ids and marking stream_ids as orphaned.
Why? Let's consider a situation, when:

  1. A request is sent by send_request. Let's call this invocation of send_request "the first invocation".
  2. The request sent by the first invocation gets a stream_id = 1.
  3. Connection's router executes this request and, after receiving response, stream_id = 1 goes back to pool.
  4. Next, another request is sent by another invocation of send_request (let's call it "the second invocation").
  5. The second request also gets a stream_id = 1, because it was already available in a pool.
  6. Now, if a late orphanage notification somehow arrives from the first invocation of send_request, it would mark the wrong request (request currently having stream_id = 1 sent by the send_request second invocation).

One way to get rid of this problem, is to track generations of stream_ids.

struct OrphanhoodNotifier {
    disabled: bool, // initialized to false
    stream_id: i16,
    generation: u64, // new field denoting the stream_id's generation number
    sender: UnboundedSender<i16>,
}

On the router side, an associative array containing mapping stream_id -> current generation number would be kept. When a new stream_id is allocated, it's generation number inside the associative array is incremented. If a orphanhood notification is received, there should be check if the generations match (the one in notification and other in associative array). After check succeeds, stream_id can be considered as orphaned. If the check does not succeed, we can be sure, that the router already received the response, and the stream_id was reused.

I believe this design is better than keeping BTreeMap queue of stream IDs, because it allows to handle timeouts by future cancellation (and therefore allows us to implement timeouts without knowing the timeout duration in advance). Being able to handle cancellations is crucial as the implementation of speculative execution heavily depends on them (by using select!).

@psarna
Copy link
Contributor Author

psarna commented Mar 4, 2022

Noble cause, but the implementation is severely outdated. Closing this one, we'll get it rewritten.

@psarna psarna closed this Mar 4, 2022
@Lorak-mmk Lorak-mmk deleted the client_side_timeouts branch October 12, 2023 13:34
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.

Implement tracking orphaned stream ids Implement client-side timeouts
3 participants