18 KiB
Plugin Chassis ABI Contract
This document is the canonical reference for what the chassis ABI promises to plugin authors and to the proxysql core. Read this before reasoning about plugin-vs-core compatibility. If anything here drifts from include/ProxySQL_Plugin.h, the header wins and this document is wrong — file an issue.
For the reviewer's guide that situates this in the larger PR, see REVIEW_GUIDE.md. For the API a plugin author writes against, see ../PLUGIN_API.md.
1. The two surfaces
The chassis exposes two ABI surfaces:
- Descriptor surface — what a plugin's
.soexports. Defined ininclude/ProxySQL_Plugin.h. Stable across feature tiers. Tail-extensible. - Services surface — what the chassis injects into the plugin (function pointers the plugin calls back through). Also defined in
include/ProxySQL_Plugin.h. Also tail-extensible.
A plugin compiled against ABI version N is loadable by a chassis that supports PROXYSQL_PLUGIN_ABI_VERSION_MAX >= N. The reverse — a future plugin against an older chassis — is rejected at load time.
2. The descriptor surface
The plugin must export exactly one symbol:
extern "C" const ProxySQL_PluginDescriptor* proxysql_plugin_descriptor_v1();
The function's return value is a pointer to a static ProxySQL_PluginDescriptor whose lifetime is tied to the .so's lifetime. The chassis dereferences this pointer immediately after dlopen.
ProxySQL_PluginDescriptor fields, in order
| Field | Type | Required? | Read by chassis when |
|---|---|---|---|
name |
const char* (non-null, non-empty) |
yes | always |
abi_version |
uint32_t (must be in [1, PROXYSQL_PLUGIN_ABI_VERSION_MAX]) |
yes | always |
init |
function pointer | NULL allowed | Phase D |
start |
function pointer | NULL allowed | Phase E |
stop |
function pointer | NULL allowed | shutdown |
status_json |
function pointer | NULL allowed | when SHOW PLUGIN STATUS is implemented (not yet) |
register_schemas |
function pointer | NULL allowed | Phase B, only when abi_version >= 2 |
Rules:
- The fields must appear in the order above. Reordering breaks ABI.
- Fields can only be appended in future ABI versions, never inserted in the middle.
- A NULL function pointer means "the plugin opts out of this phase". For example, a plugin with
start = nullptrstill loads and inits, but never spawns its own threads. - The chassis MUST NOT read past the last field defined for
abi_version. ABI-1 plugins do not haveregister_schemas; reading it would be an out-of-bounds access.
Validation at load time
The chassis (lib/ProxySQL_PluginManager.cpp:324–383) enforces:
dlsymresolvesproxysql_plugin_descriptor_v1. Else: load fails.- The function returns non-null. Else: load fails.
descriptor->nameis non-null and non-empty. Else: load fails.descriptor->abi_version >= 1 && <= PROXYSQL_PLUGIN_ABI_VERSION_MAX. Else: load fails with "unsupported plugin ABI version".descriptor->register_schemas, if read at all, is read with the predicatedescriptor->abi_version >= 2u.
Current ABI version
#define PROXYSQL_PLUGIN_ABI_VERSION 3
#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 3
ABI evolution so far:
- ABI 1 → ABI 2: appends
register_schemasto the descriptor (four-phase lifecycle). ABI-1 plugins skip Phase B entirely. - ABI 2 → ABI 3: descriptor layout is unchanged. The single addition is a
register_runtime_viewcallback at the tail ofProxySQL_PluginServices(see §3 below). ABI-2 plugins keep loading on an ABI-3 core: their compiled-againstProxySQL_PluginServicessimply ends one field earlier, and core never dereferences the trailing field for them. The accept range remains[1, PROXYSQL_PLUGIN_ABI_VERSION_MAX].
Future ABI versions append fields. The chassis bumps PROXYSQL_PLUGIN_ABI_VERSION_MAX and gates each new field's read on abi_version >= N.
3. The services surface
When the chassis calls into the plugin (Phase B register_schemas, Phase D init, Phase E start, shutdown stop), it passes a const ProxySQL_PluginServices*. The plugin uses this to call back into the core: registering tables/commands, registering query hooks, getting DB handles, logging.
Phase-availability matrix
The services struct is the same shape in every phase, but some function pointers behave differently depending on which phase the plugin is in. This is the single most surprising thing about the chassis; get it wrong and you get a phantom-success that breaks at runtime.
| Service field | Phase B (register_schemas) |
Phase D (init) |
Phase E (start) |
Steady state |
|---|---|---|---|---|
register_table |
live | live | n/a | n/a |
register_command |
live | live | n/a | n/a |
register_command_alias |
live | live | n/a | n/a |
log_message |
live | live | live | live |
get_admindb |
returns nullptr | live | live | live |
get_configdb |
returns nullptr | live | live | live |
get_statsdb |
returns nullptr | live | live | live |
register_query_hook |
returns false (warn) | live | n/a | n/a |
get_prometheus_registry |
live | live | live | live |
register_runtime_view (ABI 3) |
live | live | n/a | n/a |
Reasons:
- DB handles in Phase B — the admin module hasn't initialized yet, so the SQLite handles don't exist. Returning a nullptr from a stub is safer than not installing the function pointer (a misbehaving plugin sees a nullptr return instead of crashing on a null function pointer call).
register_query_hookin Phase B — query hooks are registered incommands_/mysql_query_hook_/pgsql_query_hook_, which Phase D writes and workers read lock-free. Phase B is too early; the plugin is told "no" and warned.register_runtime_viewin Phase B — runtime views are typically declared alongside the editable tables they project, so the callback is wired live in BOTHservices_phase_b_andservices_(Phase D). Plugins may also register frominit.- Registration after Phase D — the chassis does not currently support live registration. Once Phase D returns,
register_table/register_command/register_runtime_vieware not called by anyone. This is by design — see §6 for the worker-thread visibility argument.
Field stability
The services struct is tail-extensible. The chassis fills the struct in declaration order and the plugin reads what it knows about. A plugin compiled against ABI 2 still loads on the current ABI-3 chassis: its compiled-against ProxySQL_PluginServices ends at register_command_alias and the chassis simply doesn't dereference the trailing register_runtime_view for that plugin. Same rule applies for any future ABI-N additions.
The reverse — a future plugin trying to call a field that doesn't exist on the current chassis — would crash. The chassis prevents this by rejecting plugins whose abi_version > PROXYSQL_PLUGIN_ABI_VERSION_MAX.
4. C++ ABI coupling — read this carefully
The chassis ABI is not pure C. It uses std::string and prometheus::Registry* in callback signatures (specifically ProxySQL_PluginQueryHookPayload, ProxySQL_PluginCommandResult, get_prometheus_registry). This means:
Plugins MUST be compiled with the same C++ standard library and the same prometheus-cpp version as the proxysql core.
In practice, this means a plugin .so should be:
- Compiled with the same
-std=c++17flag. - Linked against the same libstdc++ ABI.
- Compiled with the same prometheus-cpp headers (the chassis vendors prometheus-cpp under
deps/prometheus-cpp; plugins should source from the same). - Compiled with matching feature-tier flags (
-DPROXYSQL40 -DPROXYSQL31 -DPROXYSQLFFTO -DPROXYSQLTSDB -DPROXYSQLGENAI). Mismatched tier flags silently change struct layouts inProxySQL_PluginDescriptor/ProxySQL_PluginServicesbecause some inline#ifdefblocks add fields. The mysqlx plugin Makefile pulls these from the environment and propagates them; the top-level Makefile sets them from the build flag.
The mysqlx plugin Makefile (plugins/mysqlx/Makefile:56–61) carries an explicit comment about this. The CI workflow .github/workflows/CI-mysqlx.yml passes the flags explicitly to the sub-make.
Hidden-visibility hardening
The mysqlx plugin is built with -fvisibility=hidden -fvisibility-inlines-hidden. Only proxysql_plugin_descriptor_v1 is exported (it's extern "C"). This:
- Prevents ODR collisions with the proxysql core.
- Avoids leaking template instantiations across the dlopen boundary.
- Makes RTLD_LOCAL meaningful — without hidden visibility, the plugin's symbols are still in the .so's dynsym table even if dlopen says they're local.
Plugin authors should follow the same pattern.
dlopen mode
The chassis loads with RTLD_NOW | RTLD_LOCAL. This means:
RTLD_NOW: all symbols are resolved at load time. A plugin with unresolved symbols fails to load (rather than crashing on first use).RTLD_LOCAL: the plugin's symbols are not added to the global namespace. Two plugins that happen to define the same symbol name don't collide. But: this also means the plugin cannot rely on transitive dependencies of the proxysql binary being visible — if the plugin needslibzstd, it must linklibzstditself (statically is recommended; the mysqlx plugin does this).
5. Separation of duties between Admin and the plugin module
This is the central behavioural contract for chassis-driven LOAD/SAVE commands. It is documented in full at the bottom of include/ProxySQL_Plugin.h and at length in doc/PLUGIN_API.md. Briefly:
- Admin owns the editable, persistent configuration tables (e.g.
mysqlx_users,mysqlx_routes). - The plugin module owns the runtime state — typically an in-memory snapshot kept under its own mutex (e.g.
MysqlxConfigStore). - The
runtime_<X>table inadmin_dbis not module storage; it is an admin-side view of module state, projected on demand by a callback the plugin registers viaservices.register_runtime_view(...).
Therefore:
LOAD <X> TO RUNTIMEreads the editable admin table and hands the rows to the module via a typed install API that swaps state under the module's own lock. It MUST NOT touchruntime_<X>.SAVE <X> [FROM RUNTIME] TO MEMORYdumps the module's in-memory state andREPLACE INTOs the editable admin table. It MUST NOT readruntime_<X>.runtime_<X>is repopulated by the registered refresh callback before any admin SELECT touches it. Admin's pre-SELECT hook walks every registered view and invokes the callback for any view whose table name is referenced as a whole identifier in the SQL query (case-insensitive; identifier-aware, soruntime_<X>_extraorstats_runtime_<X>do not matchruntime_<X>).
Disk-tier copies (LOAD <X> FROM DISK, SAVE <X> TO DISK) are the exception: those DO copy between configdb and admindb persistent tables, and they remain plain BEGIN/DELETE/INSERT/COMMIT with checked rollback. For those, the empty-source-must-still-clear-destination rule still applies — a DELETE FROM mysqlx_users; SAVE MYSQLX USERS TO DISK; must leave the disk table empty, not preserve the previous rows. PR #5643 fixed an early mysqlx implementation that omitted the unconditional DELETE on the disk path.
The reference for the runtime-view path is plugins/mysqlx/src/mysqlx_admin_schema.cpp (each load_<X>_to_runtime callback calls MysqlxConfigStore::install_<X>_from_admin; each save_<X>_from_runtime calls save_<X>_to_admin_table; four free refresh_<X>_runtime_view callbacks are wired via services.register_runtime_view).
6. Concurrency model and lifecycle invariants
The chassis is single-threaded during startup and shutdown but multi-threaded during steady-state. The boundary is:
- Phase A → B → C → D → E run on the main thread, in order, with no concurrent access to
commands_,mysql_query_hook_,pgsql_query_hook_, ortables_on the manager. - After Phase E returns, the main thread is done. Worker threads (created by
start) take over. - Workers read
commands_and*_query_hook_viaproxysql_dispatch_configured_plugin_*andproxysql_has_configured_plugin_query_hook. The first goes throughg_active_plugin_manager_mutex(astd::shared_mutex— readers share, writers take unique). The second is plain atomic load and is documented to allow false positives.
The single load-bearing invariant: Phase D must finish before any worker thread takes the lock-free read path. If it didn't — if start returned and workers began running before Phase D's writes to commands_ settled — workers' plain reads would race the manager's plain writes from Phase D. The chassis enforces this by not calling start_all until init_all has returned.
stop runs on the main thread again, after worker threads have been signaled to exit. Plugins must ensure their stop callback waits for any threads it spawned in start.
Lifecycle pairing
Critical: stop pairs with init, not with start. Concretely:
- If a plugin's
initsucceeds andstartthen fails,stopSTILL runs. - If a plugin's
initfails,stopdoes NOT run. - If a plugin's
initfails for plugin B in a multi-plugin load, plugin A'sstopstill runs (because A's init succeeded).
This is the only correct teardown discipline — start failures must release whatever init acquired.
Verified by test/tap/tests/unit/plugin_manager_unit-t.cpp:test_multi_plugin_start_failure_stops_started.
7. ABI versioning rules
The chassis follows these rules for ABI evolution:
- Increment
PROXYSQL_PLUGIN_ABI_VERSIONfor any descriptor or services change. - Append, never insert. New fields go at the end of the relevant struct.
- Gate every new field's read. When the chassis dereferences a field that's only valid for
abi_version >= N, the read must be insideif (descriptor->abi_version >= Nu). - Plugins set their own
abi_versionto whatever their compile-time header had. This is the contract for "what fields I have". The chassis'sPROXYSQL_PLUGIN_ABI_VERSION_MAXis the contract for "what fields I know how to read". - A future ABI 3 must not change the layout of fields that exist at ABI 2. Otherwise an ABI-2 plugin stops being loadable.
The current public API surface (ProxySQL_PluginDescriptor + ProxySQL_PluginServices + the query-hook payload/result/action types) is not yet versioned individually. A future change might (e.g.) add a ProxySQL_PluginServices_v3 for the second wave of services. The chassis is structured so this addition is a tail-append on the descriptor (new_services field) rather than a new struct.
What can the chassis do, but plugins should not?
The chassis can:
- Pass
nullptras a service pointer to indicate "this service is unavailable in this phase". Plugin code must null-check. - Reject a plugin whose
abi_versionis unrecognised. Plugins must accept this and exit cleanly. - Tear down a plugin (
stop+dlclose) at any time afterinitsucceeded.
Plugins must NOT:
- Cache
servicespointers across phases. The struct may differ between phases (Phase B vs Phase D services are two distinct objects in the chassis; they happen to be ABI-compatible but the function pointers differ). - Hold references to
SQLite3DB*paststop. Afterstopreturns, the admin module may tear down the DB. - Spawn threads outside
start. The chassis only signals workers via the worker-shutdown path triggered bystop; threads created elsewhere have no clean shutdown. - Modify their own descriptor at runtime. The chassis caches the pointer.
8. Reference: minimal plugin skeleton
#include "ProxySQL_Plugin.h"
#include <atomic>
static std::atomic<bool> g_running{false};
static bool my_init(const ProxySQL_PluginServices* services) {
services->log_message(0, "my_plugin: init");
// ... acquire resources, register query hooks, etc.
return true;
}
static bool my_register_schemas(const ProxySQL_PluginServices* services) {
static const char* my_table_ddl =
"CREATE TABLE IF NOT EXISTS my_plugin_config ("
" name TEXT PRIMARY KEY, value TEXT)";
ProxySQL_PluginTableDef def{"my_plugin_config", my_table_ddl, ProxySQL_PluginDBKind::admin_db};
services->register_table(def);
return true;
}
static bool my_start(const ProxySQL_PluginServices* services) {
g_running.store(true);
// spawn whatever threads/listeners the plugin needs
return true;
}
static bool my_stop(const ProxySQL_PluginServices* services) {
g_running.store(false);
// join threads, close listeners
return true;
}
static const ProxySQL_PluginDescriptor descriptor = {
"my_plugin", // name
PROXYSQL_PLUGIN_ABI_VERSION, // abi_version (= 3)
my_init, // init (Phase D)
my_start, // start (Phase E)
my_stop, // stop
nullptr, // status_json (not yet implemented)
my_register_schemas // register_schemas (Phase B)
};
extern "C" const ProxySQL_PluginDescriptor* proxysql_plugin_descriptor_v1() {
return &descriptor;
}
That's the minimum a chassis-aware plugin needs. The mysqlx plugin is the reference for how this scales up — see REVIEW_GUIDE.md §5 and FILE_CHANGES.md areas A–L.
9. Versioning discipline going forward
Anyone extending the chassis ABI in the future must:
- Bump
PROXYSQL_PLUGIN_ABI_VERSIONandPROXYSQL_PLUGIN_ABI_VERSION_MAXininclude/ProxySQL_Plugin.h. - Append the new field at the END of the relevant struct.
- Gate every read of the new field on
abi_version >= NEW_VERSION. - Update
PLUGIN_API.mdwith the new field's contract. - Update this document's §2 (descriptor) or §3 (services) to add a row for the new field.
- Add a unit test in
test/tap/tests/unit/plugin_lifecycle_unit-t.cpp(or wherever appropriate) that exercises (a) a plugin compiled at the previous ABI version still loads and runs, and (b) the new field is reachable when set.
If a future change ever needs to break ABI compatibility — i.e., it cannot be expressed as a tail-append — that's a hard rebuild for every shipped plugin and should be deferred until a major version bump.