fix(plugin): make the hot reload of plugins transactional - #13720
Draft
AlinsRan wants to merge 7 commits into
Draft
fix(plugin): make the hot reload of plugins transactional#13720AlinsRan wants to merge 7 commits into
AlinsRan wants to merge 7 commits into
Conversation
`plugin.load()` used to tear the live plugin tables down before rebuilding them: it destroyed every old plugin, cleared `local_plugins` / `local_plugins_hash` in place, and then re-required and re-`init()`ed the plugins one by one, with no protection and no rollback. An unprotected `init()` therefore leaves the worker permanently broken: the plugins that had already been loaded stay in the array (`load_plugin` inserted before calling `init()`, so even the failing plugin stays), the `local_plugins_hash` rebuild never runs so the hash stays empty and the Admin API rejects every plugin with `unknown plugin [...]`, and which plugins survive depends on the `pairs()` order, so it differs per worker. The error is swallowed by the worker-events pcall while the reload endpoint has already answered `200 done`. Rework `load()` / `load_stream()` into three phases: 1. build the new plugin set in a local table, so the tables read by the request path are untouched while modules are required; 2. destroy the old instances, then run the `init()` / `workflow_handler()` hooks of the new ones under pcall. On failure, destroy the new instances, restore the `package.loaded` snapshot and re-init the old instances, then return the error; 3. on success, repopulate the live tables in place. The old instances are destroyed before the new ones are initialized (rather than the other way around) because plugins such as server-info and log-rotate register timers globally by name, so an overlap would let the old `destroy()` unregister the timer the new instance just registered. The live tables are repopulated in place rather than swapped, because `_M.plugins` and the `ipairs(plugin_mod.plugins)` readers in api_router / control router alias them; there is no yield point between the clear and the end of the loop, so no request can observe a partially updated set. That also closes the second part of the issue: modules are required before the commit, not during it. The Admin `/apisix/admin/plugins/reload` and Control `/v1/plugins/reload` endpoints now load on the serving worker first and only broadcast the event if that succeeds, so a plugin set that cannot be loaded is reported as `500` instead of an unconditional `200 done`. The event handlers skip the originating worker id to avoid loading twice. Fixes apache#13087
test() returns (status, body, headers) on an error, so `code, _, body` put the headers table into body and ngx.say aborted on it — the whole block produced empty output. Take the body as the second return value. Also declare the expected init() error so the default no_error_log guard does not flag it, and tolerate the create-route status (200 on EE, 201 upstream) plus the trailing newline in the error body.
load()/load_stream() cleared package.loaded for the whole target plugin set before requiring, so the very first load (init_worker) re-required every plugin and threw away the module instances initialized in init_by_lua. A plugin's destroy() registered there was lost (t/node/plugin.t), and any module-level init done in init_by_lua ran twice. Match master: drop only the currently-loaded set (empty on first load, so those modules are reused), which still re-reads code from disk on a reload.
The rollback of an aborted reload destroyed every instance of the new plugin set, including the ones sitting after the failing plugin, whose init() had never run. destroy() of an uninitialized instance publishes its uninitialized state: gm and ocsp-stapling assign their nil upvalue back to radixtree_sni.set_cert_and_key, which breaks the SSL handshake path and is then saved as the "original" function by the rollback, so the damage survives the reload. Both plugins have a lower priority than almost everything else, so any failing init() reaches them. Destroy only the instances whose init() completed, and do it in the reverse order of init(), so that plugins wrapping a shared function unwind their chain innermost first. The old instances are destroyed in reverse order too, for the same reason.
The reverse-order destroy had no coverage: reverting it alone left the suite green. TEST 4 asserts the hook sequence of two probe plugins across an aborted reload, which pins both the LIFO unwind of the old set and the fact that a reload failing on the very first plugin (inited = 0) destroys nothing of the new set. The comment claimed the innermost wrapper is removed first, which is the opposite of what the code does: gm installs first and is the inner layer, so it is ocsp-stapling, the outermost, that has to go first.
exit_worker() iterated the hash, i.e. in an arbitrary order, which is the one remaining path that does not respect the unwind order the reload now guarantees. Impact is near zero since the process is going away, but leaving it inconsistent invites the next reader to copy the wrong pattern.
…ransaction # Conflicts: # apisix/admin/init.lua # apisix/control/v1.lua
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes #13087.
plugin.load()tears the live plugin tables down before rebuilding them: it destroys every old plugin, clearslocal_plugins/local_plugins_hashin place, then re-requires and re-init()s the plugins one by one, with nopcalland no rollback.If one
init()throws, the worker is left permanently broken:load_plugin()inserts into the array before callinginit(), so even the failing plugin stays in the served array;local_plugins_hashrebuild loop never runs, so the hash stays empty and the Admin API rejects every plugin withunknown plugin [...];pairs()hash order, so it differs per worker;pcall, and the reload endpoint has already answered200 done.Reproduced on master: after a reload with one plugin whose
init()throws,plugin.plugins=[bad-init],plugin.plugins_hash={}, the previously working route silently loses itsresponse-rewrite, and re-PUTing the exact same route conf returns400 unknown plugin [response-rewrite]. The state is permanent until the next successful reload.The same structure is also what makes the second half of the issue possible: the live tables are cleared and then repopulated across
require()/init()calls, which can yield, so an in-flight request can traverse a partial plugin set.Solution
load()/load_stream()become a three-phase transaction:require()and the completeness checks all happen here; the tables the request path reads are untouched, so nothing is observable yet.init()/workflow_handler()of the new instances, all underpcall. If any hook fails: destroy the new instances that were initialized, restore thepackage.loadedsnapshot, re-init()the old instances, and return the error. The live tables were never touched.Three deliberate choices that are not the obvious ones:
server-info,log-rotateanderror-log-loggercalltimers.register_timer("plugin#<name>", ...)ininit()andtimers.unregister_timer(...)indestroy(). If the new instance registered first and the old one was destroyed afterwards, the olddestroy()would unregister the timer the new instance had just registered — a "successful" reload that silently loses background tasks. Keeping today's order and implementing the rollback as "re-init()the old instances" avoids that;init/destroyare already written to be re-runnable across reloads.init()actually ran, in the reverse order ofinit().destroy()of an instance that was never initialized publishes its uninitialized state:gmandocsp-staplingassign their still-nilupvalue back toradixtree_sni.set_cert_and_key(andapisix_ssl.check_ssl_conf), which breaks the SSL handshake path — and the rollback then saves thatnilas the "original" function when it re-init()s the old instance, so the damage outlives the reload. Both plugins have a lower priority than almost anything else (-43/-44), so they sit at the end of the array and any failinginit()reaches them. The reverse order matters for the same pair:gm(priority-43) installs first and is the inner layer,ocsp-stapling(-44) wraps it, so the wrappers must be unwound LIFO. With the previous forward destroy, even a successful reload leftset_cert_and_keypointing at the stale old-gmwrapper —gm.destroy()restored the real function andocsp.destroy()then overwrote it withgm's wrapper — permanently leaking the old module into the call chain. The old instances are destroyed in reverse order too, for that reason._M.pluginsis bound tolocal_pluginsat module load time, andapisix/api_router.luaandapisix/control/router.luaread it viaipairs(plugin_mod.plugins); swapping the upvalue would leave those readers holding a dead table. In-place repopulation keeps the table identity, and there is no yield point between theclearand the end of the loop, so a concurrent request cannot observe a half-built set.ctx.pluginsholds plugin object references, so in-flight requests finish against the old objects exactly as they do today.init()moves out ofload_plugin()into the caller, which is what makes "insert before init" stop mattering.require/ priority / version / schema failures keep today's "log and skip" semantics — that is also the cold-start behaviour, and such a failure does not corrupt anything (the plugin is simply absent, array and hash stay consistent). Onlyinit/workflow_handlerfailures, which have side effects, abort the reload. Making the reload stricter about unknown plugin names is a separate discussion.Behaviour change (please read)
This changes the public behaviour of the reload endpoints:
PUT /apisix/admin/plugins/reloadandPUT /v1/plugins/reloadnow perform the load synchronously on the serving worker first, and only broadcast the event to the other workers if it succeeded.500with the error message instead of200 done, and no event is broadcast, so the other workers are not asked to break themselves too.200 done.I think the current
200 doneis the worse behaviour: the operator is told the reload succeeded while the plugin table is corrupted and the Admin API has started rejecting every plugin. But this will be visible to anyone whose automation asserts "reload always returns 200", so it deserves discussion rather than a silent merge.The event handlers skip the originating worker (via the worker id the events frame carries) so the serving worker does not load twice.
Rebased on the reconciliation work that landed meanwhile (#13714, #13745): the version is now bumped after the synchronous load succeeded, not before, so a plugin set which cannot load neither reaches the other workers nor arms the reconciliation timer — otherwise every worker would retry the same broken set once a second, and each attempt tears the live plugin set down and rebuilds it. For the same reason
reload_plugins_and_sync()records the sampled version even when the load failed. The serving worker also stores the version it just bumped, so its own timer does not redo the load one second later.Tests
New
t/admin/plugins-reload-transaction.t, plus test-only fixtures:t/apisix/plugins/reload-bad-init.lua, whoseinit()always throws, andt/apisix/plugins/reload-probe.lua/reload-probe-2.lua, which record their lifecycle hooks intot/lib/reload_probe_state.lua(kept outside theapisix.pluginspackage so the loader never drops it frompackage.loaded, hence the counters survive a reload). Both are resolved through the existing$apisix_home/t/?.luapackage path, so they do not ship in the release.TEST 1 reloads a plugin list that keeps
response-rewriteand adds the throwing plugin, then asserts: the endpoint returns500withfailed to init plugin [reload-bad-init] ... boom;plugin.pluginsis still exactly[response-rewrite];plugin.plugins_hashstill containsresponse-rewrite; re-PUTing the identical route conf still returns200; and/hellostill returns the rewritten body.TEST 2 runs a failing reload followed by a good one and asserts the good one returns
200 doneand the new plugin set is live — i.e. a failed reload leaves no sticky state.TEST 3 covers the rollback itself.
reload-probehas a lower priority thanreload-bad-init, so when the reload aborts its new instance has never been initialized. Assertsinit=2 destroy=1 destroy_without_init=0: the old instance was destroyed once and re-init()ed by the rollback, the new one was never touched.TEST 4 pins the hook order. Two probes (
reload-probe411,reload-probe-2410) record everyinit/destroyinto the shared state; the reload addsreload-bad-init(412), which is now the highest priority of the three, so it fails before any new instance is initialized. Asserts the sequenceprobe:init probe-2:init probe-2:destroy probe:destroy probe:init probe-2:init— i.e. the old set is unwound LIFO and restored in init order — and covers theinited = 0boundary, where nothing of the new set may be destroyed.Every assertion of TEST 1 fails on current master: the endpoint returns
200, the array is[reload-bad-init]or a random remnant, the hash is empty, the routePUTreturns400 unknown plugin [response-rewrite]and/helloreturnshello world. TEST 3 and TEST 4 discriminate against earlier revisions of this PR: with the whole new set destroyed on rollback TEST 3 reportsdestroy=2 destroy_without_init=1, and with the forward destroy restored TEST 4 reportsprobe:destroy probe-2:destroy. Both were verified by reverting each half of the fix separately.Existing coverage that should stay green:
t/admin/plugins-reload.t(theload plugin times: 2/start to hot reload pluginscounts are preserved by the skip-self design),t/control/plugins-reload.t,t/plugin/example.t(theplugin.load()return contract), and the by-name-timer pluginst/plugin/log-rotate.t/t/plugin/prometheus4.t.Locally I ran
t/admin/plugins-reload-transaction.t,t/control/plugins-reload.t,t/plugin/example.tandt/plugin/log-rotate.t— all green.t/admin/plugins-reload.tTEST 7 fails on my machine, but it fails identically on the base commit without this patch, so it is a local environment issue rather than a regression;t/plugin/prometheus4.tI have not run. Relying on CI for the rest.Checklist