Repository navigation
fix(core): Overhaul worker shutdown to make sure all tasks are executed before terminating - #1704
maciejdudko wants to merge 3 commits into
Conversation
…ed before terminating
|
CI timed out: This is a spurious failure, didn't happen in the first run - may have introduced a new concurrency bug. I'm going to investigate. But the PR is still reviewable. |
| // p5 outstanding and final poll outstanding -- hence one permit available | ||
| assert_eq!(core.available_wft_permits(), Some(1)); | ||
| assert_eq!(core.cached_workflows().await, 3); | ||
| // TODO: the worker is leaking, we should complete all activations and call shutdown. |
There was a problem hiding this comment.
Not leftover, intentional. It's a very complex test and I decided to punt on it for now. The leak is not related to PR changes, it was always doing that.
| shutdown_token | ||
| .into_iter() | ||
| .for_each(CancellationToken::cancel); |
There was a problem hiding this comment.
Nit: Could just be an if-let? Or I guess maybe that's annoying with the cfg
| // However, we want to avoid plowing ahead until we've been asked to poll at | ||
| // least once. This supports activity-only workers. |
There was a problem hiding this comment.
Nothing to do with your changes but I'm realizing some earlier comment must've been dropped at some point and now this reads strangely.
| // However, we want to avoid plowing ahead until we've been asked to poll at | |
| // least once. This supports activity-only workers. | |
| // We want to avoid plowing ahead until we've been asked to poll at | |
| // least once. This supports activity-only workers. |
|
|
||
| /// Send a `BumpStream` message to the workflow stream. This ensures that any pending | ||
| /// workflow activation polls will resolve, like during shutdown, even if there are no other inputs. | ||
| /// Populates the workflow stream with no-op message. This ensures that any pending workflow |
There was a problem hiding this comment.
Does it actually "populate" it now though? Since it doesn't send it as an input?
| /// Populates the workflow stream with no-op message. This ensures that any pending workflow | |
| /// Wakes the workflow stream with no-op message. This ensures that any pending workflow |
| #[allow(dead_code)] | ||
| rc: Arc<HistfetchRC>, |
There was a problem hiding this comment.
I think this works?
| #[allow(dead_code)] | |
| rc: Arc<HistfetchRC>, | |
| _rc: Arc<HistfetchRC>, |
| let max_duration = Duration::from_secs(70); // matches temporalio_client::LONG_POLL_TIMEOUT | ||
| let retry_options = RetryOptions::builder() | ||
| .initial_interval(Duration::from_secs(1)) | ||
| .multiplier(2.0) |
There was a problem hiding this comment.
Not sure we'd actually want to back off that much?
There was a problem hiding this comment.
This matches our default throttle backoff config. I did add max_interval(Duration::from_secs(10)) though.
There was a problem hiding this comment.
More I meant the multiplier. I'm wondering if we want to just use 1.0 here, or 1.1. Mostly b/c I'm thinking of the case where the first RPC error is just a blip, we save a whole second on shutdown. Not a huge deal either way though.
| #[cfg(debug_assertions)] | ||
| if let Err(e) = res && let Ok(e) = e.try_into_panic() { | ||
| // In debug build, propagate panics. | ||
| if cfg!(debug_assertions) { |
There was a problem hiding this comment.
The else branch of this can never run since the entire block is #[cfg(debug_assertions)]
| @@ -331,24 +328,35 @@ where | |||
| self.outstanding_permits.fetch_add(1, Ordering::Release); | |||
There was a problem hiding this comment.
Does these also need to become SeqCst? How did you determine that was required?
I think there's actually still a race here too:
- A reads
close_requested== false. - B calls
close(), sees 0 outstanding permits, and cancels the token. - A increments the count and gets a permit.
At that point close_complete has already fired and the eager-activity channel has been closed, so add_tasks fails and the eager activity is dropped. Seems we need to fetch_add first, then check close_requested, and if it is set, decrement again and return error.
There was a problem hiding this comment.
Yeah, I think you're right. Need to lock them behind a single mutex.
| } | ||
| false | ||
|
|
||
| if !self.is_poller_stopped { |
There was a problem hiding this comment.
Because we now wait completely for the workflow pollers to stop, there's a problem where we could hang forever if we lose connection to the server. This can happen because the client (can) internally retries the errors over and over. We can add a regression test that proves this with mock clients I think.
We probably need a way to set a shutdown token for a retrying client.
There was a problem hiding this comment.
I fixed the problem using a semaphore rather than a token so that it doesn't look like we're getting fatal error on shutdown.
| handle_task_panic(self.poller_task, None).await; | ||
| } | ||
|
|
There was a problem hiding this comment.
This should still call the shutdown token I think, which is why it was originally stored in self. It looks like all callers are doing this, but, it seems more maintainable to keep that in here too.
|
Pushed a commit that addresses all review feedback. Unfortunately there are merge conflicts that will take some effort to resolve, as both this PR and main branch made significant changes to the combined normal/sticky WFT poller. Additionally, the |
Does fixing #1704 not fix that test? LLMs are pretty great at resolving merge conflicts too, would definitely try to lean on them there. |
|
@maciejdudko I think this would fix #1753 Plz check if it does and we can close at the same time |
What was changed
Changes in worker shutdown:
Poller::polltake&mut selfto forbid concurrent polls.Poller::notify_shutdownas it was redundant with worker shutdown token.LongPollBufferstops, it yields a singleSome(PollerError::PollerStopped)before yieldingNone. This replaces inserting dead poller message in the wrapping streams.ShutdownWorkerRPC will retry for as long as pollers are still polling, to avoid long shutdown on transient RPC failure.LocalInputs::BumpStreamwithWFStreamInput::CheckShutdownthat doesn't clog the WFT input stream.Other changes in implementation:
ClosableMeteredPermitDealer.ThrottleRetryConfigForCallandRetryCallbackrequest extensions for use byShutdownWorkerRPC.SlotKindtrait now has boundsSend + Sync + 'staticin the definition so that they don't have to be added ad-hoc.ExternalPollerInputstype since it's now redundant.Changes in tests:
make_wft_stream_interminablewithkeep_wft_pending_until_worker_shutdownbecause interminable WFT stream now blocks shutdown.setup_mock_worker_clientandsetup_manual_mock_worker_clientto simplify overriding default mock client behavior.Why?
This PR addresses a number of bugs in worker shutdown procedure that caused lost tasks or lost task permits on shutdown, mostly caused by not draining the workflow task poller before finalizing shutdown. This was most prominent in a large number of spurious test failures when running locally, but it also has potential to impact users in real world production scenarios.
Checklist
Closes Activity poll can hang during shutdown when ShutdownWorker returns UNIMPLEMENTED #1297 (note this is only one of the problems being fixed, the others don't have an issue open).
How was this tested:
Unit tests and integration tests were adapted to the new behavior.