diff --git a/include/ProxySQL_Plugin.h b/include/ProxySQL_Plugin.h index 1a0c1c7f1..d1d1e67d4 100644 --- a/include/ProxySQL_Plugin.h +++ b/include/ProxySQL_Plugin.h @@ -23,13 +23,14 @@ namespace prometheus { class Registry; } // ABI 1: original 6-field descriptor (name, abi_version, init, start, // stop, status_json). Pre-chassis build. // ABI 2: appends `register_schemas` (four-phase lifecycle, PROXYSQL40). -// -// A v3 core (built without PROXYSQL40) only understands ABI 1 and MUST -// reject ABI>=2 plugins — it would read past the end of its own struct -// definition. A v4 core accepts ABI 1 plugins by treating the -// register_schemas field as if null (never dereferenced on ABI 1). -#define PROXYSQL_PLUGIN_ABI_VERSION 2u -#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 2u +// ABI 3: same descriptor layout as ABI 2; ProxySQL_PluginServices grows +// a `register_runtime_view` field at the end so plugins can +// declare admin-side projections of module state. Plugins that +// stay on ABI 2 keep working — they simply don't see the new +// field in their compiled-against struct, and core never +// dereferences past the ABI-2 layout for them. +#define PROXYSQL_PLUGIN_ABI_VERSION 3u +#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 3u enum class ProxySQL_PluginDBKind : uint8_t { admin_db = 0, @@ -166,6 +167,48 @@ using proxysql_plugin_register_query_hook_cb = // scraped immediately. using proxysql_plugin_get_prometheus_registry_cb = prometheus::Registry* (*)(); + +// Runtime-view projection (ABI 3 extension). +// +// Mirrors the canonical mysql_users / runtime_mysql_users pattern: Admin +// owns the editable table (e.g. mysqlx_users), the plugin module owns +// the runtime state (e.g. MysqlxConfigStore::identities_), and the +// runtime_ in admin_db is an Admin-side *view* projected on +// demand from the module. The view holds no persistent rows — it is +// wiped and refilled by the plugin's refresh callback before any admin +// SELECT touches it. +// +// Plugins call register_runtime_view(view) during register_schemas or +// init. Each registration ties an admin-db table name (e.g. +// "runtime_mysqlx_users") to a refresh callback. Admin's pre-SELECT +// hook walks every registered view and invokes the callback for any +// that the SQL references. +// +// The refresh callback gets a borrowed admindb handle and is expected +// to do (typically) `BEGIN; DELETE FROM
; INSERT/REPLACE INTO +//
...; COMMIT;` from the module's in-memory state. The chassis +// passes admindb explicitly (rather than the plugin reaching for it via +// services.get_admindb) because the same callback might be invoked +// against snapshot DBs in the future without the global-getter detour. +// +// Lifetime: the chassis deep-copies `table_name`, so the plugin need +// not keep the pointed-to string alive. The callback pointer itself +// must point at a function with static lifetime (typically a free +// function in the plugin .so). `opaque` is plugin-owned; it is passed +// back unchanged on each invocation. Plugins that don't need it should +// pass nullptr. +// +// Returns true on successful registration, false if `table_name` is +// already registered (by this or another plugin) or if `refresh` is +// nullptr. +struct ProxySQL_PluginRuntimeView { + const char *table_name; + void (*refresh)(SQLite3DB *admindb, void *opaque); + void *opaque; +}; + +using proxysql_plugin_register_runtime_view_cb = + bool (*)(const ProxySQL_PluginRuntimeView &); #endif /* PROXYSQL40 */ // Services provided to plugins across the four-phase lifecycle. @@ -219,6 +262,16 @@ struct ProxySQL_PluginServices { // plugins avoid the MYSQLX-specific hardcoded alias ladder that // previously lived in lib/Admin_Handler.cpp. proxysql_plugin_register_command_alias_cb register_command_alias; + + // ABI-3 extension: declare an admin-side view of module state + // (canonical pattern, mirrors Admin's own runtime_mysql_users / + // save_mysql_users_runtime_to_database flow). See the contract + // next to ProxySQL_PluginRuntimeView above. May be nullptr in + // services_phase_b_ — register_runtime_view is live both during + // register_schemas() and init(); plugins typically register views + // at the same point they register their tables, so the callback + // is wired in both phases. + proxysql_plugin_register_runtime_view_cb register_runtime_view; #endif /* PROXYSQL40 */ }; @@ -285,39 +338,43 @@ struct ProxySQL_PluginDescriptor { using proxysql_plugin_descriptor_v1_t = const ProxySQL_PluginDescriptor *(*)(); // --------------------------------------------------------------------------- -// ABI guidance: disk/memory/runtime sync — empty-source MUST still clear -// the destination. -// -// Plugins that implement a three-tier storage model (disk ↔ memory ↔ -// runtime, mirroring proxysql_admin) typically copy rows from one tier -// into another via some variant of: -// -// BEGIN; -// DELETE FROM dest; -// INSERT INTO dest SELECT * FROM source; -// COMMIT; +// ABI guidance: separation of duties between Admin and the plugin module. // -// Do NOT short-circuit this on `SELECT COUNT(*) FROM source == 0`. Early -// return on an empty source leaves stale rows in the destination — the -// exact bug that motivated PR #5643 on the mysqlx plugin, where +// 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. +// The runtime_
in admin_db is NOT module storage; it is an +// Admin-side *view* of module state, projected on demand. // -// if (disk_cnt == 0) continue; // stale rows in runtime -// if (cnt == 0) continue; // stale rows in memory +// LOAD TO RUNTIME callbacks should: +// - read the editable admin table directly, +// - hand the rows to the module via a typed install API that swaps +// state under the module's own lock, +// - NOT touch runtime_ at all. // -// caused "I deleted every row from mysqlx_users on disk then reloaded, -// but the runtime still has the old users" behavior across restarts. +// SAVE [FROM RUNTIME] TO MEMORY callbacks should: +// - dump the module's in-memory state, +// - REPLACE INTO the editable admin table, +// - NOT read runtime_. // -// The correct invariant: after sync, `dest` contains exactly the rows -// from `source` at the moment of the transaction. An empty source -// produces an empty destination. Atomicity with ROLLBACK on any -// intermediate error keeps the destination in a well-defined state on -// failure. +// The runtime_ view is repopulated by a refresh callback registered +// via services.register_runtime_view(...). Admin invokes the callback +// before any SELECT against the registered table; the callback wipes +// the table and re-projects from the module's in-memory state. This +// matches the canonical MySQL_Authentication / runtime_mysql_users +// pattern in core (lib/ProxySQL_Admin.cpp::save_mysql_users_runtime_to_ +// database). It does duplicate the data briefly (in module + in the +// projected admin table during a query), and that is the point: the +// module is isolated from Admin's persistence, and operators see a +// faithful snapshot whenever they query. // -// Applies to every LOAD/SAVE command a plugin registers. If you -// register admin commands that copy between tiers (LOAD X TO RUNTIME, -// SAVE X TO DISK, etc.), their callbacks MUST NOT skip the sync on an -// empty source — run the DELETE+INSERT unconditionally inside a single -// transaction and check each execute() return. +// Disk-tier copies (LOAD X FROM DISK, SAVE X TO DISK) DO copy between +// configdb and admindb persistent tables and remain a plain +// BEGIN/DELETE/INSERT/COMMIT — there is no module involvement and no +// view projection on that path. For those copies, the empty-source- +// must-still-clear-destination rule still applies (see PR #5643): run +// the DELETE+INSERT unconditionally inside a single transaction and +// check each execute() return. // --------------------------------------------------------------------------- #endif /* PROXYSQL40 (file-wide) */ diff --git a/include/ProxySQL_PluginManager.h b/include/ProxySQL_PluginManager.h index 31bb19f2b..3d08a6c1a 100644 --- a/include/ProxySQL_PluginManager.h +++ b/include/ProxySQL_PluginManager.h @@ -65,6 +65,20 @@ public: bool dispatch_query_hook(ProxySQL_PluginProtocol proto, const ProxySQL_PluginQueryHookPayload& payload, ProxySQL_PluginQueryHookResult& result) const; + + // Runtime-view (admin-side projection of module state) plumbing. + // register_runtime_view returns false if the table is already + // registered or if the refresh callback is null. + bool register_runtime_view(const ProxySQL_PluginRuntimeView& view); + + // Refresh every registered view whose table_name appears as a + // case-insensitive substring of `sql`. Each refresh callback is + // invoked exactly once per call, regardless of how many times its + // table is mentioned. Best-effort: a callback that throws or + // otherwise misbehaves is logged but does not stop other views from + // refreshing. Caller supplies admindb so the chassis does not have + // to reach into the global admin module. + void refresh_runtime_views_for_query(const std::string& sql, SQLite3DB* admindb) const; #endif /* PROXYSQL40 */ size_t size() const; @@ -115,6 +129,18 @@ private: // At most one hook per protocol; nullptr means "no hook". proxysql_plugin_query_hook_cb mysql_query_hook_ { nullptr }; proxysql_plugin_query_hook_cb pgsql_query_hook_ { nullptr }; + + // Runtime-view registry: one entry per admin-side projection of + // module state. Stored alongside an owned table_name copy so + // callers may free the input string after registration. The + // refresh callback pointer and opaque are plugin-owned with + // static lifetime (the .so isn't unloaded while a view is live). + struct registered_runtime_view_t { + std::string table_name {}; + void (*refresh)(SQLite3DB*, void*) { nullptr }; + void* opaque { nullptr }; + }; + std::vector runtime_views_ {}; #endif /* PROXYSQL40 */ }; @@ -141,6 +167,14 @@ bool proxysql_has_configured_plugin_query_hook(ProxySQL_PluginProtocol proto); // release the manager lock before dispatching without risking pointer // invalidation on concurrent reload. std::string proxysql_resolve_configured_plugin_admin_alias(const std::string& sql); + +// Admin-side helper: invoke every plugin runtime-view refresh callback +// whose registered table is referenced by `sql`. Used by Admin's +// pre-SELECT path, mirroring the way runtime_mysql_users is refreshed +// before its SELECTs. No-op if no plugin manager is active or no views +// match. Caller supplies admindb (typically the same handle Admin uses +// for its own runtime_mysql_users refresh). +void proxysql_refresh_configured_plugin_runtime_views(const std::string& sql, SQLite3DB* admindb); // Phase A + B of the four-phase lifecycle: dlopen() each module, read its // descriptor, then call register_schemas() on plugins that opted in. On // success, `manager` is populated AND installed as the active manager so diff --git a/lib/ProxySQL_Admin.cpp b/lib/ProxySQL_Admin.cpp index 7d2bec1f6..2daacc516 100644 --- a/lib/ProxySQL_Admin.cpp +++ b/lib/ProxySQL_Admin.cpp @@ -1872,6 +1872,17 @@ bool ProxySQL_Admin::GenericRefreshStatistics(const char *query_no_space, unsign } #endif /* PROXYSQLCLICKHOUSE */ +#ifdef PROXYSQL40 + // Plugin-registered runtime views (canonical pattern, mirrors + // the runtime_mysql_users refresh above): each plugin declares + // its admin-side view of module state via + // services.register_runtime_view(); the chassis dispatcher + // invokes the registered refresh callback for any view whose + // table name is referenced in this query, before the query + // runs against admindb. + proxysql_refresh_configured_plugin_runtime_views(query_no_space, admindb); +#endif /* PROXYSQL40 */ + } if (monitor_mysql_server_group_replication_log) { if (GloMyMon) { diff --git a/lib/ProxySQL_PluginManager.cpp b/lib/ProxySQL_PluginManager.cpp index 479b67b8c..444979a7a 100644 --- a/lib/ProxySQL_PluginManager.cpp +++ b/lib/ProxySQL_PluginManager.cpp @@ -161,6 +161,21 @@ bool register_query_hook_service(ProxySQL_PluginProtocol proto, } return true; } + +bool register_runtime_view_service(const ProxySQL_PluginRuntimeView& view) { + if (g_registry_target == nullptr) { + proxy_warning("Plugin runtime-view registration attempted outside init/register_schemas phase\n"); + return false; + } + if (!g_registry_target->register_runtime_view(view)) { + note_registration_failure("plugin runtime view", + view.table_name != nullptr ? view.table_name : "(null)"); + proxy_warning("Plugin runtime-view registration failed for %s\n", + view.table_name != nullptr ? view.table_name : "(null)"); + return false; + } + return true; +} #endif /* PROXYSQL40 */ SQLite3DB* get_admindb_service() { @@ -286,6 +301,7 @@ ProxySQL_PluginManager::ProxySQL_PluginManager() { services_.register_query_hook = ®ister_query_hook_service; services_.get_prometheus_registry = &get_prometheus_registry_service; services_.register_command_alias = ®ister_command_alias_service; + services_.register_runtime_view = ®ister_runtime_view_service; // Phase-B (register_schemas) services: same layout as init(), but DB // handle getters and the query-hook registrar are stubbed -- see the @@ -306,6 +322,11 @@ ProxySQL_PluginManager::ProxySQL_PluginManager() { // register_command() first, then register aliases. Since register_command // is also available during Phase B, so is register_command_alias. services_phase_b_.register_command_alias = ®ister_command_alias_service; + // Runtime-view registration is live during register_schemas: views are + // declared alongside tables, well before init() runs. The actual + // refresh callback won't fire until Admin handles a SELECT, by which + // point admin module bootstrap has long since completed. + services_phase_b_.register_runtime_view = ®ister_runtime_view_service; #endif /* PROXYSQL40 */ } @@ -739,6 +760,65 @@ bool ProxySQL_PluginManager::dispatch_query_hook(ProxySQL_PluginProtocol proto, result = cb(payload); return true; } + +bool ProxySQL_PluginManager::register_runtime_view(const ProxySQL_PluginRuntimeView& view) { + if (view.table_name == nullptr || *view.table_name == '\0' || view.refresh == nullptr) { + return false; + } + for (const auto& existing : runtime_views_) { + if (strcasecmp(existing.table_name.c_str(), view.table_name) == 0) { + return false; + } + } + registered_runtime_view_t entry; + entry.table_name = view.table_name; + entry.refresh = view.refresh; + entry.opaque = view.opaque; + runtime_views_.push_back(std::move(entry)); + return true; +} + +namespace { + +// Case-insensitive substring check matching whole identifier-like +// occurrences. We don't want a SELECT against `runtime_mysqlx_users` +// to also fire the refresh for `runtime_mysqlx_users_extra` if +// someone ever registers both. The match treats `[A-Za-z0-9_]` as +// identifier characters and requires the surrounding chars (if any) +// to be non-identifier — same convention as the sql_equals_ci +// canonicaliser used elsewhere in this file. +bool is_ident_char(unsigned char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_'; +} + +bool sql_references_table_ci(const std::string& sql, const std::string& table_name) { + if (table_name.empty() || table_name.size() > sql.size()) { + return false; + } + for (size_t i = 0; i + table_name.size() <= sql.size(); i++) { + if (strncasecmp(sql.data() + i, table_name.data(), table_name.size()) != 0) { + continue; + } + const bool left_ok = (i == 0) || !is_ident_char(static_cast(sql[i - 1])); + const size_t after = i + table_name.size(); + const bool right_ok = (after == sql.size()) || !is_ident_char(static_cast(sql[after])); + if (left_ok && right_ok) { + return true; + } + } + return false; +} + +} // namespace + +void ProxySQL_PluginManager::refresh_runtime_views_for_query(const std::string& sql, SQLite3DB* admindb) const { + for (const auto& view : runtime_views_) { + if (view.refresh == nullptr) continue; + if (!sql_references_table_ci(sql, view.table_name)) continue; + view.refresh(admindb, view.opaque); + } +} #endif /* PROXYSQL40 */ bool ProxySQL_PluginManager::register_command(const char* sql, proxysql_plugin_admin_command_cb cb) { @@ -891,6 +971,20 @@ bool proxysql_has_configured_plugin_query_hook(ProxySQL_PluginProtocol proto) { } return mgr->has_query_hook(proto); } + +void proxysql_refresh_configured_plugin_runtime_views(const std::string& sql, SQLite3DB* admindb) { + // Reader: shared lock. The refresh callbacks themselves write to + // admindb (DELETE+REPLACE INTO the projected runtime_ + // table) and read from the plugin's own in-memory store under that + // store's own lock. They must not call back into the plugin + // manager (no nested lock acquisition). + std::shared_lock lock(g_active_plugin_manager_mutex); + ProxySQL_PluginManager* mgr = g_active_plugin_manager.load(); + if (mgr == nullptr) { + return; + } + mgr->refresh_runtime_views_for_query(sql, admindb); +} #endif /* PROXYSQL40 */ bool proxysql_load_configured_plugins(