fix(plugin): make the hot reload of plugins transactional#13720
Draft
AlinsRan wants to merge 3 commits into
Draft
fix(plugin): make the hot reload of plugins transactional#13720AlinsRan wants to merge 3 commits into
AlinsRan wants to merge 3 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.
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, restore thepackage.loadedsnapshot, re-init()the old instances, and return the error. The live tables were never touched.Two 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._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.A maintainer previously said a proposal for this would be welcome — this PR is that proposal in runnable form. Happy to change direction on any of the above; opened as a draft for that reason.
The event handlers skip the originating worker (via the worker id the events frame carries) so the serving worker does not load twice.
Tests
New
t/admin/plugins-reload-transaction.tplus a fixture plugint/apisix/plugins/reload-bad-init.luawhoseinit()always throws (resolved through the existing$apisix_home/t/?.luapackage path, so it does not ship in the release).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.200 doneand the new plugin set is live — i.e. a failed reload leaves no sticky state.Every one of those assertions 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. So the test discriminates — reverting the fix turns TEST 1 red on all five assertions.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.I have not run the test suite locally; relying on CI here.
Checklist