doc/plugin-chassis/ABI.md
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.
The chassis exposes two ABI surfaces:
.so exports. Defined in include/ProxySQL_Plugin.h. Stable across feature tiers. Tail-extensible.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.
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:
start = nullptr still loads and inits, but never spawns its own threads.abi_version. ABI-1 plugins do not have register_schemas; reading it would be an out-of-bounds access.The chassis (lib/ProxySQL_PluginManager.cpp:324–383) enforces:
dlsym resolves proxysql_plugin_descriptor_v1. Else: load fails.descriptor->name is 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 predicate descriptor->abi_version >= 2u.#define PROXYSQL_PLUGIN_ABI_VERSION 4
#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 4
ABI evolution so far:
register_schemas to the descriptor (four-phase lifecycle). ABI-1 plugins skip Phase B entirely.register_runtime_view callback at the tail of ProxySQL_PluginServices (see §3 below). ABI-2 plugins keep loading on an ABI-3 core: their compiled-against ProxySQL_PluginServices simply ends one field earlier, and core never dereferences the trailing field for them. The accept range remains [1, PROXYSQL_PLUGIN_ABI_VERSION_MAX].ProxySQL_PluginDescriptor and ProxySQL_PluginServices layouts are unchanged. The change is in ProxySQL_PluginRuntimeView, which gains a db_kind field (ProxySQL_PluginDBKind) appended at the tail of the struct. The chassis now dispatches the correct DB handle (admindb/configdb/statsdb) to the refresh callback based on db_kind. ABI-3 plugins that initialize ProxySQL_PluginRuntimeView with {table_name, refresh, opaque} (3-field aggregate init) automatically get db_kind = admin_db (value 0) via zero-initialization of the trailing field — matching the pre-ABI-4 behaviour without any detection code.Future ABI versions append fields. The chassis bumps PROXYSQL_PLUGIN_ABI_VERSION_MAX and gates each new field's read on abi_version >= N.
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.
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:
register_query_hook in Phase B — query hooks are registered in commands_ / 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_view in Phase B — runtime views are typically declared alongside the editable tables they project, so the callback is wired live in BOTH services_phase_b_ and services_ (Phase D). Plugins may also register from init.register_table / register_command / register_runtime_view are not called by anyone. This is by design — see §6 for the worker-thread visibility argument.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.
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:
-std=c++17 flag.deps/prometheus-cpp; plugins should source from the same).-DPROXYSQL40 -DPROXYSQL31 -DPROXYSQLFFTO -DPROXYSQLTSDB -DPROXYSQLGENAI). Mismatched tier flags silently change struct layouts in ProxySQL_PluginDescriptor / ProxySQL_PluginServices because some inline #ifdef blocks 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.
The mysqlx plugin is built with -fvisibility=hidden -fvisibility-inlines-hidden. Only proxysql_plugin_descriptor_v1 is exported (it's extern "C"). This:
Plugin authors should follow the same pattern.
dlopen modeThe 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 needs libzstd, it must link libzstd itself (statically is recommended; the mysqlx plugin does this).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:
mysqlx_users, mysqlx_routes).MysqlxConfigStore).runtime_<X> table in admin_db is not module storage; it is an admin-side view of module state, projected on demand by a callback the plugin registers via services.register_runtime_view(...).Therefore:
LOAD <X> TO RUNTIME reads 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 touch runtime_<X>.SAVE <X> [FROM RUNTIME] TO MEMORY dumps the module's in-memory state and REPLACE INTOs the editable admin table. It MUST NOT read runtime_<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, so runtime_<X>_extra or stats_runtime_<X> do not match runtime_<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).
The chassis is single-threaded during startup and shutdown but multi-threaded during steady-state. The boundary is:
commands_, mysql_query_hook_, pgsql_query_hook_, or tables_ on the manager.start) take over.commands_ and *_query_hook_ via proxysql_dispatch_configured_plugin_* and proxysql_has_configured_plugin_query_hook. The first goes through g_active_plugin_manager_mutex (a std::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.
Critical: stop pairs with init, not with start. Concretely:
init succeeds and start then fails, stop STILL runs.init fails, stop does NOT run.init fails for plugin B in a multi-plugin load, plugin A's stop still 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.
The chassis follows these rules for ABI evolution:
PROXYSQL_PLUGIN_ABI_VERSION for any descriptor or services change.abi_version >= N, the read must be inside if (descriptor->abi_version >= Nu).abi_version to whatever their compile-time header had. This is the contract for "what fields I have". The chassis's PROXYSQL_PLUGIN_ABI_VERSION_MAX is the contract for "what fields I know how to read".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.
The chassis can:
nullptr as a service pointer to indicate "this service is unavailable in this phase". Plugin code must null-check.abi_version is unrecognised. Plugins must accept this and exit cleanly.stop + dlclose) at any time after init succeeded.Plugins must NOT:
services pointers 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).SQLite3DB* past stop. After stop returns, the admin module may tear down the DB.start. The chassis only signals workers via the worker-shutdown path triggered by stop; threads created elsewhere have no clean shutdown.#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 (= 4)
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.
Anyone extending the chassis ABI in the future must:
PROXYSQL_PLUGIN_ABI_VERSION and PROXYSQL_PLUGIN_ABI_VERSION_MAX in include/ProxySQL_Plugin.h.abi_version >= NEW_VERSION.PLUGIN_API.md with the new field's contract.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.