Skip to content

fix(plugin): make the hot reload of plugins transactional#13720

Draft
AlinsRan wants to merge 3 commits into
apache:masterfrom
AlinsRan:fix/plugin-reload-transaction
Draft

fix(plugin): make the hot reload of plugins transactional#13720
AlinsRan wants to merge 3 commits into
apache:masterfrom
AlinsRan:fix/plugin-reload-transaction

Conversation

@AlinsRan

Copy link
Copy Markdown
Contributor

Description

Fixes #13087.

plugin.load() tears the live plugin tables down before rebuilding them: it destroys every old plugin, clears local_plugins / local_plugins_hash in place, then re-requires and re-init()s the plugins one by one, with no pcall and no rollback.

If one init() throws, the worker is left permanently broken:

  • load_plugin() inserts into the array before calling init(), so even the failing plugin stays in the served array;
  • the local_plugins_hash rebuild loop never runs, so the hash stays empty and the Admin API rejects every plugin with unknown plugin [...];
  • which plugins survive depends on the pairs() hash order, so it differs per worker;
  • the error is swallowed by the worker-events pcall, and the reload endpoint has already answered 200 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 its response-rewrite, and re-PUTing the exact same route conf returns 400 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:

  1. build — the new plugin set is built in a local table. require() and the completeness checks all happen here; the tables the request path reads are untouched, so nothing is observable yet.
  2. switch hooks — destroy the old instances, then run init() / workflow_handler() of the new instances, all under pcall. If any hook fails: destroy the new instances, restore the package.loaded snapshot, re-init() the old instances, and return the error. The live tables were never touched.
  3. commit — repopulate the live tables in place.

Two deliberate choices that are not the obvious ones:

  • Old instances are destroyed before the new ones are initialized, not after. Several in-tree plugins register global resources by name: server-info, log-rotate and error-log-logger call timers.register_timer("plugin#<name>", ...) in init() and timers.unregister_timer(...) in destroy(). If the new instance registered first and the old one was destroyed afterwards, the old destroy() 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/destroy are already written to be re-runnable across reloads.
  • The live tables are repopulated in place, not swapped. _M.plugins is bound to local_plugins at module load time, and apisix/api_router.lua and apisix/control/router.lua read it via ipairs(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 the clear and the end of the loop, so a concurrent request cannot observe a half-built set. ctx.plugins holds plugin object references, so in-flight requests finish against the old objects exactly as they do today.

init() moves out of load_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). Only init / workflow_handler failures, 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/reload and PUT /v1/plugins/reload now perform the load synchronously on the serving worker first, and only broadcast the event to the other workers if it succeeded.
  • On failure they return 500 with the error message instead of 200 done, and no event is broadcast, so the other workers are not asked to break themselves too.
  • The success path is unchanged: still 200 done.
  • The endpoints are now slower on the success path, since the request does a full load (require + init hooks) instead of only posting an event. Reload is a low-frequency operational call, so this seemed the right trade.

I think the current 200 done is 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.t plus a fixture plugin t/apisix/plugins/reload-bad-init.lua whose init() always throws (resolved through the existing $apisix_home/t/?.lua package path, so it does not ship in the release).

  • TEST 1 reloads a plugin list that keeps response-rewrite and adds the throwing plugin, then asserts: the endpoint returns 500 with failed to init plugin [reload-bad-init] ... boom; plugin.plugins is still exactly [response-rewrite]; plugin.plugins_hash still contains response-rewrite; re-PUTing the identical route conf still returns 200; and /hello still returns the rewritten body.
  • TEST 2 runs a failing reload followed by a good one and asserts the good one returns 200 done and 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 route PUT returns 400 unknown plugin [response-rewrite] and /hello returns hello 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 (the load plugin times: 2 / start to hot reload plugins counts are preserved by the skip-self design), t/control/plugins-reload.t, t/plugin/example.t (the plugin.load() return contract), and the by-name-timer plugins t/plugin/log-rotate.t / t/plugin/prometheus4.t.

I have not run the test suite locally; relying on CI here.

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (if not, please discuss on the APISIX mailing list first)

AlinsRan added 3 commits July 20, 2026 15:31
`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.
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.

bug: the hot reload procedure is not robust

1 participant