Destroy replaced configs on ET_TASK - #13491
Conversation
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Moves destruction of replaced ConfigInfo objects off network threads by scheduling the work onto ET_TASK, and adds debug logging to report destruction duration and thread.
Changes:
- Schedule deferred config release (
ConfigInfoReleaser) onET_TASKinstead of the default event type. - Route last-reference destruction (
ConfigProcessor::release) toET_TASKwhen possible via a destroyer continuation. - Add debug logging for per-config destruction time and executing thread.
a91f7c2 to
8c28775
Compare
ConfigProcessor::set() scheduled the deferred destruction of the replaced config with schedule_in(), which defaults to ET_CALL, so a network thread ran the destructor 60 seconds later inside the drain phase of its event loop. The destructor blocks that thread for as long as the config takes to release, which is bounded only by the size of the config. ConfigProcessor::release() is the only place a config is destroyed, and two callers reach it: the releaser at 60 seconds, which destroys the config whenever nothing else still holds a reference, and a transaction that outlived the releaser and drops the last reference itself. Schedule the releaser on ET_TASK, and hand the destructor from the transaction path to ET_TASK as well, so neither can block a network thread. The 60 second wait is unchanged. Shortening it would narrow the window that makes the load-then-increment in get() safe. The config debug tag now reports the duration of each destruction and the thread that ran it.
8c28775 to
e355bf8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/iocore/eventsystem/ConfigProcessor.cc:208
- The debug log uses
%dforid, butidis anunsigned int. This varargs type mismatch is undefined behavior; use%uto match the argument type.
Dbg(dbg_ctl_config, "Release config %d %p", id, info);
src/iocore/eventsystem/ConfigProcessor.cc:53
destroy_config()callsink_get_hrtime()unconditionally, even though the timing is only used when theconfigdebug tag is enabled. This adds avoidable overhead in the non-debug case; you can early-return whendbg_ctl_configis off and only measure/log when it’s on (and useDbg()for consistency with the rest of the file).
destroy_config(unsigned int id, ConfigInfo *info)
{
ink_hrtime start = ink_get_hrtime();
delete info;
|
[approve ci autest 1] |
destroy_config_on_task_thread() also refused the hand off when thread_group[ET_TASK]._count read 0, to cover the gap between tasksProcessor.register_event_type() and tasksProcessor.start(). A config cannot be destroyed in that gap. release() reaches the destructor only when the reference count drops to zero, and the processor gives up its own reference only through the ConfigInfoReleaser that set() schedules timeout_secs ahead, 60 seconds for every caller in the tree. Both spans where the count reads 0 are straight-line startup code on the main thread, so nothing can drop a reference inside them. The check was also the wrong test for that gap. spawn_event_threads() stores _thread[i] inside its construction loop and sets _count after the loop, so for most of the gap the count still reads 0 while _thread[0] is already a usable thread.
The destroyer is created and scheduled in one breath and nothing else can reach it, so the mutex serialized nothing and cost a ProxyMutex allocation per destroyed config. EThread::process_event() treats a null event mutex as already acquired, which is how FreeCallContinuation, FreerContinuation and DereferContinuation already run.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/iocore/eventsystem/ConfigProcessor.cc:96
- The comment says ET_TASK behaves as ET_CALL until task threads are registered and that an ET_NET caller should then destroy inline, but the code doesn’t actually detect that state. If ET_TASK still maps to ET_CALL here,
schedule_imm(..., ET_TASK)can still enqueue the destructor work onto the caller’s (potentially ET_NET) loop, reintroducing the drain-phase blocking this PR aims to avoid. Consider explicitly checking whether ET_TASK threads are registered/available (e.g., via an eventProcessor query for ET_TASK thread count/availability) and returning false when they aren’t, rather than relying onis_event_type(ET_TASK).
EThread *ethread = this_ethread();
// ET_TASK is ET_CALL until the task threads are registered, so before that point an ET_NET caller
// destroys the config on its own thread.
if (ethread == nullptr || ethread->is_event_type(ET_TASK)) {
return false;
}
ConfigInfoDestroyer *destroyer = new ConfigInfoDestroyer(id, info);
if (eventProcessor.schedule_imm(destroyer, ET_TASK) == nullptr) {
// The event system is shutting down and will never run the destroyer.
delete destroyer;
return false;
}
src/iocore/eventsystem/ConfigProcessor.cc:51
- This uses
PRId64but the shown includes don’t include<inttypes.h>/<cinttypes>. Depending on transitive includes can break portability/builds. Add the appropriate standard header in this file (preferred:<cinttypes>in C++), or use an existing project-provided formatting utility if one is standard in this codebase.
DbgPrint(dbg_ctl_config, "Destroyed config %u in %" PRId64 " ns on thread %s", id, ink_get_hrtime() - start, thread_name);
The destruction path had no coverage. Autest never reached it: a config is destroyed only after ConfigProcessor::set() releases the config it replaced, and that timeout is a compile time constant of 60 seconds, which outlives every existing test. The test replaces two configs, one through the reload framework and one from a network thread by way of a record update, waits out the timeout and checks that the debug line reports an ET_TASK thread for both. It also fails if any config is destroyed on a network thread. The 60 second wait keeps it out of CI, so it is skipped the same way post_slow_server is.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/iocore/eventsystem/ConfigProcessor.cc:88
destroy_config_on_task_thread()returnsfalsewhenthis_ethread()isnullptr, which forces inline destruction even thougheventProcessor.schedule_imm(..., ET_TASK)can be called from non-EThread contexts. This can still block whatever thread is dropping the last ref (including non-event threads). Consider removing theethread == nullptrearly-return and instead only falling back to inline destruction whenET_TASKscheduling is unavailable (e.g., during shutdown / no task threads).
destroy_config_on_task_thread(unsigned int id, ConfigInfo *info)
{
EThread *ethread = this_ethread();
// ET_TASK is ET_CALL until the task threads are registered, so before that point an ET_NET caller
// destroys the config on its own thread.
if (ethread == nullptr || ethread->is_event_type(ET_TASK)) {
return false;
}
tests/gold_tests/config_processor/config_destroy_thread.test.py:29
- This test is unconditionally skipped, so it won’t prevent regressions in automated runs. Instead of a hard skip, consider gating it behind an env/flag (e.g.,
Condition.EnvironmentVariableSet(...)) or placing it in an explicitly-run slow/soak suite so it can be executed in at least one CI lane.
Test.SkipIf(Condition.true("Test takes over 60 seconds to run."))
There was a problem hiding this comment.
Approving, although I do not think it is wise to lock this in as a requirement. I don't think this should have an AuTest. This is a performance optimization and we should be able to change our minds and do something else without waiting for a major release. I would argue that adding the AuTest here is an unofficial guarantee that users can rely on this behavior.
I understand your concern. I'm not trying to guarantee this behavior going forward with the autest. My goal with the test is to ensure that the code change does what it's supposed to. My understanding about autest is that they're not set in stone, even within one major release, and they can be changed when the expected behavior of the code changes. |
* Destroy replaced configs on ET_TASK ConfigProcessor::set() scheduled the deferred destruction of the replaced config with schedule_in(), which defaults to ET_CALL, so a network thread ran the destructor 60 seconds later inside the drain phase of its event loop. The destructor blocks that thread for as long as the config takes to release, which is bounded only by the size of the config. ConfigProcessor::release() is the only place a config is destroyed, and two callers reach it: the releaser at 60 seconds, which destroys the config whenever nothing else still holds a reference, and a transaction that outlived the releaser and drops the last reference itself. Schedule the releaser on ET_TASK, and hand the destructor from the transaction path to ET_TASK as well, so neither can block a network thread. The 60 second wait is unchanged. Shortening it would narrow the window that makes the load-then-increment in get() safe. The config debug tag now reports the duration of each destruction and the thread that ran it. (cherry picked from commit 46be2f5)
|
Cherry-picked to the 10.2.x branch as 745b2e7 for the 10.2.0 release. |
The deferred destruction of a replaced config runs on a network thread today.
ConfigProcessor::set()schedules it withschedule_in(), which defaults to ET_CALL, so 60 seconds later an ET_NET thread runs the destructor inside the drain phase of its event loop and is blocked for as long as the config takes to release, which is bounded only by the size of the config.Move that work to ET_TASK.
ConfigProcessor::release()is the only place a config is destroyed, but two callers reach it: the releaser at 60 seconds, which destroys the config whenever nothing else still holds a reference, and a transaction that outlived the releaser and drops the last reference itself. This schedules the releaser on ET_TASK and hands the destructor from the transaction path to ET_TASK as well, so neither can block a network thread.The 60 second wait is unchanged. Shortening it would narrow the window that makes the load-then-increment in
ConfigProcessor::get()safe, so it should not be tuned just because the destructor moved.The
configdebug tag now reports the duration of each destruction and the thread that ran it.