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

QUIC hot restart part 6 - child instance pauses listening until parent is drained #31130

Merged
merged 19 commits into from
Feb 29, 2024

Conversation

ravenblackx
Copy link
Contributor

@ravenblackx ravenblackx commented Nov 30, 2023

Commit Message: QUIC hot restart part 6 - child instance pauses listening until parent is drained
Additional Description: Make the child instance not read from the UDP socket until the parent instance stops reading. From the prior changes, the parent instance forwards UDP packets it doesn't recognize to the child instance via a side-channel, so not reading doesn't mean not responding - it just means not intercepting packets which may be intended for the parent instance.

In this change there is no drain optimization - if the parent has no more live connections we will still wait and forward packets until the drain time completes. I think it is most likely not worth optimizing for that, since the eventual goal is to migrate the QUIC-packet-sorting behavior onto eBPF at which point, for systems that care about performance, all of this manual forwarding support will be unused anyway.

I've tried to keep entirely of the performance hot path, so the change only touches constructors and enable/disable operations and stays entirely out of the read path.

It's awkward because the point at which we detect that the socket is being forwarded (the creation of the UdpListenSocket) the UdpListener and ActiveQuicListener don't exist, but those are where we need to disable or enable reading. Also awkward is the potential race - the unpause callback can potentially arrive after the listener was deleted, and must go to the right thread, so there's a bit of a tangle ensuring that that's the case - for this we still call the callback, but abort if the listener was deleted by the time the dispatcher tries to perform the action.

Finally, there's a potential race of disable/enable and this paused state, so I've added enough state to the UdpListener that the sequence start_paused->disable->unpause does not accidentally end with the listener enabled.

Risk Level: Some risk, this is touching quite a bit of core code. The touches should mostly not do anything except to QUIC listeners, so the risk isn't as big as it looks.
Testing: Unit tests provide coverage; I intend to augment hotrestart_handoff test to make sure this is fully doing what it needs to do for QUIC listeners.
Docs Changes:
Release Notes:
Platform Specific Features:

Copy link

As a reminder, PRs marked as draft will not be automatically assigned reviewers,
or be handled by maintainer-oncall triage.

Please mark your PR as ready when you want it to be reviewed!

🐱

Caused by: #31130 was opened by ravenblackx.

see: more, trace.

Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx ravenblackx changed the title Hot restart 6 [WIP] QUIC hot restart 6 [WIP] Dec 12, 2023
Copy link
Contributor

@ggreenway ggreenway left a comment

Choose a reason for hiding this comment

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

I think this is a reasonable approach overall. But I think you'll need a way to un-register for these callbacks, in the case that the quic listener is removed via LDS after the child has started, but before the parent has finished draining.

I'd also like to get at least one more opinion on this, maybe oneof @wbpcode @soulxu @alyssawilk @yanavlasov.

envoy/network/listen_socket.h Outdated Show resolved Hide resolved
@@ -34,20 +34,49 @@ UdpListenerImpl::UdpListenerImpl(Event::Dispatcher& dispatcher, SocketSharedPtr
: BaseListenerImpl(dispatcher, std::move(socket)), cb_(cb), time_source_(time_source),
// Default prefer_gro to false for downstream server traffic.
config_(config, false) {
parent_drained_callback_registry_ =
dynamic_cast<const UdpListenSocket&>(*socket_).parentDrainedCallbackRegistry();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is a dynamic_cast needed? Can the types be changed so that this isn't needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Couldn't upgrade the types because it cascaded back up through multiple layers to eventually interfaces which, in order to be able to use the specified type, caused a circular dependency.
So instead I put a virtual function on the base Socket type, which is yuck, but works cleanly and doesn't cause any kind of dependency cascade.

* @param callback the function to call when the listener matching address is
* drained on the parent instance.
*/
virtual void registerParentDrainedCallback(std::string address,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the address a string instead an address shared_ptr?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now a (const reference to) an address shared_ptr. It was an ill-considered simplification (it still ends up as a string in the end because we can't make a map keyed on address objects) - stringing it early reduces dependencies and makes it simpler to test, but also reduces how much it makes sense.

Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx
Copy link
Contributor Author

ravenblackx commented Dec 13, 2023

I think this is a reasonable approach overall. But I think you'll need a way to un-register for these callbacks, in the case that the quic listener is removed via LDS after the child has started, but before the parent has finished draining.

That's handled as-is by the callback self-aborting, rather than by unregistering. I don't think unregistering can work reliably because the destructor may not be called on the thread that owns the registry (it could work but would require a network of locks and the behavior would become much less clear if destruction and unpausing are trying to be concurrent - having the callback abort on-thread if the destructor has happened means the "race" occurs exclusively in the worker thread, so there can be no attempted concurrency between destruction and notification).

@soulxu
Copy link
Member

soulxu commented Dec 14, 2023

I think this is a reasonable approach overall. But I think you'll need a way to un-register for these callbacks, in the case that the quic listener is removed via LDS after the child has started, but before the parent has finished draining.

I'd also like to get at least one more opinion on this, maybe oneof @wbpcode @soulxu @alyssawilk @yanavlasov.

Or we only register the thread worker as the callbacks, then we can use the existing disableListeners/enableListeners method (maybe add a new flag to disable all the udp listener) of ConnectionHandler to pause/unpause the listener.

void disableListeners() override;
void enableListeners() override;

@ravenblackx
Copy link
Contributor Author

I think this is a reasonable approach overall. But I think you'll need a way to un-register for these callbacks, in the case that the quic listener is removed via LDS after the child has started, but before the parent has finished draining.
I'd also like to get at least one more opinion on this, maybe oneof @wbpcode @soulxu @alyssawilk @yanavlasov.

Or we only register the thread worker as the callbacks, then we can use the existing disableListeners/enableListeners method (maybe add a new flag to disable all the udp listener) of ConnectionHandler to pause/unpause the listener.

The problem with using the existing functions is there are already calls to those functions which could potentially make something racily do the wrong thing, e.g. if a listener starts up and is disabled by a call to disableListeners for hot restart purposes, then is disabled for some other reason, then hot restart draining completes and calls enableListeners, now the socket would be listening despite the other caller of disableListeners expecting it not to be. Tracking it separately ensures that the socket is only reading if both things want it to be.

So my rationale for separating out this paused state is that it can play well with other enable/disable operations with no risk of conflict. Another way to achieve this would be to add a "disabledness" counter so that two disables must be matched to two enables, but knowing whether that would be safe or not would require digging a lot more into existing code to ensure it doesn't currently expect one enable to be capable of overriding two prior disables.

Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx
Copy link
Contributor Author

/coverage

Copy link

Coverage for this Pull Request will be rendered here:

https://storage.googleapis.com/envoy-pr/31130/coverage/index.html

The coverage results are (re-)rendered each time the CI envoy-presubmit (check linux_x64 coverage) job completes.

🐱

Caused by: a #31130 (comment) was created by @ravenblackx.

see: more, trace.

Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx ravenblackx changed the title QUIC hot restart 6 [WIP] QUIC hot restart part 6 - child instance not listening until parent is drained Jan 11, 2024
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx ravenblackx changed the title QUIC hot restart part 6 - child instance not listening until parent is drained QUIC hot restart part 6 - child instance pauses listening until parent is drained Jan 11, 2024
Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx
Copy link
Contributor Author

/retest

Signed-off-by: Raven Black <ravenblack@dropbox.com>
@ravenblackx ravenblackx marked this pull request as ready for review January 19, 2024 16:23
Copy link
Contributor

@ggreenway ggreenway left a comment

Choose a reason for hiding this comment

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

Looks good overall!

/wait

socket_->connectionInfoProvider().localAddress(),
[this, &dispatcher, alive = std::weak_ptr<void>(destruction_checker_)]() {
dispatcher.post([this, alive = std::move(alive)]() {
if (alive.lock()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think to be not-racy this needs to be

auto ptr = alive.lock();
if (ptr != nullptr) {

That will guarantee that the pointer can't be deleted while unpause() is running.

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 safe because the destruction happens on the same thread, but changing as suggested because you're right that it otherwise merits a comment explaining this, and it's easier to just make it not matter. :)

source/server/hot_restarting_child.cc Show resolved Hide resolved
source/server/hot_restarting_child.cc Outdated Show resolved Hide resolved
void HotRestartingChild::allDrainsImplicitlyComplete() {
for (auto& drain_action : on_drained_actions_) {
// call the callback.
std::move(drain_action.second)();
Copy link
Contributor

Choose a reason for hiding this comment

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

What does the std::move do here? Is it needed?

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, this is required for calling an AnyInvocable to do with it being a "call once" type.

source/server/hot_restarting_child.cc Outdated Show resolved Hide resolved
test/common/network/udp_listener_impl_test.cc Outdated Show resolved Hide resolved
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Signed-off-by: Raven Black <ravenblack@dropbox.com>
Copy link
Contributor

@ggreenway ggreenway left a comment

Choose a reason for hiding this comment

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

LGTM

@ravenblackx
Copy link
Contributor Author

/retest

@ravenblackx ravenblackx merged commit f7352b3 into envoyproxy:main Feb 29, 2024
52 of 53 checks passed
@ravenblackx ravenblackx deleted the hot_restart_6 branch February 29, 2024 17:37
phlax added a commit to phlax/envoy that referenced this pull request Mar 1, 2024
…il parent is drained (envoyproxy#31130)"

This reverts commit f7352b3.

Signed-off-by: Ryan Northey <ryan@synca.io>
ravenblackx pushed a commit that referenced this pull request Mar 1, 2024
#32658)

Revert "QUIC hot restart part 6 - child instance pauses listening until parent is drained (#31130)"

This reverts commit f7352b3.

Signed-off-by: Ryan Northey <ryan@synca.io>
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.

3 participants