From fc9735f54928151cb2714d70929c5d56464996b6 Mon Sep 17 00:00:00 2001 From: meskill <8974488+meskill@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:46:43 +0000 Subject: [PATCH 1/4] docs: update docs for plugins --- .config/nextest.toml | 4 + .gitignore | 8 + CONTRIBUTING.md | 11 +- docs/PLUGIN_SYSTEM.md | 790 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 808 insertions(+), 5 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 docs/PLUGIN_SYSTEM.md diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..969bd13a7 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,4 @@ +[profile.dev] +fail-fast = true +test-threads = 1 +default-filter = "not package(rust)" diff --git a/.gitignore b/.gitignore index 25a52ef50..2988b3428 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ debug/ target/ +# IDEs +.vscode/ + # These are backup files generated by rustfmt **/*.rs.bk @@ -54,3 +57,8 @@ pgdog-plugin/src/bindings.rs local/ integration/log.txt .pycache + +# Ignore test internal files +integration/pgdog.config +integration/**/.bundle +integration/**/vendor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cbb39047..aca4a7536 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,13 +12,14 @@ Contributions are welcome. If you see a bug, feel free to submit a PR with a fix 1. Run cargo build in the project directory. 2. Install Postgres (all Pg versions supported). -3. Some tests used prepared transactions. Enable them with `ALTER SYSTEM SET max_prepared_transactions TO 1000` and restart Postgres. -4. Run the setup script `bash integration/setup.sh`. -5. Launch pgdog configured for integration: `bash integration/dev-server.sh`. -6. Run the tests `cargo nextest run --test-threads=1`. If a test fails, try running it directly. +3. Add user `pgdog` with password `pgdog`. +4. Some tests used prepared transactions. Enable them with `ALTER SYSTEM SET max_prepared_transactions TO 1000;` sql command and restart Postgres. +5. Run the setup script `bash integration/setup.sh`. +6. Run the tests `cargo nextest run --profile dev`. If a test fails, try running it directly. +7. Run the integration tests `bash integration/run.sh` or exact integration test with `bash integration/go/run.sh`. ## Coding 1. Please format your code with `cargo fmt`. -2. If you're feeeling generous, `cargo clippy` as well. +2. If you're feeling generous, `cargo clippy` as well. 3. Please write and include tests. This is production software used in one of the most important areas of the stack. diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md new file mode 100644 index 000000000..e46ed8845 --- /dev/null +++ b/docs/PLUGIN_SYSTEM.md @@ -0,0 +1,790 @@ +# PgDog Plugin System - Architecture & Execution Workflow + +This document provides a comprehensive overview of PgDog's plugin system, including its architecture, execution flow, integration points, and development guidelines. + +## Overview + +PgDog's plugin system enables dynamic, runtime customization of query routing behavior through Rust-based plugins compiled as shared libraries. The system uses FFI (Foreign Function Interface) to safely communicate between PgDog and plugins while maintaining strict version compatibility guarantees. + +## Architecture + +### Core Components + +PgDog's plugin system is built on four main crates: + +#### 1. **pgdog-plugin** - Plugin Interface Bridge +- **Location**: `pgdog-plugin/` +- **Role**: Main user-facing plugin library +- **Key Modules**: + - `plugin.rs` - Plugin lifecycle and FFI symbol loading via `libloading` + - `context.rs` - `Context` and `Route` types for plugin I/O + - `parameters.rs` - Prepared statement parameter handling + - `bindings.rs` - Auto-generated C ABI bindings via bindgen + - `prelude.rs` - Common imports (macros, types, functions) + +**Key Responsibilities**: +- Provides safe Rust interfaces for plugin development +- Manages FFI symbol resolution and execution +- Performs automatic version compatibility checks +- Re-exports `pg_query` with guaranteed version matching + +#### 2. **pgdog-macros** - Plugin Code Generation +- **Location**: `pgdog-macros/src/lib.rs` +- **Purpose**: Procedural macros that auto-generate required FFI functions + +**Exported Macros**: +```rust +macros::plugin!() +// Generates three required FFI functions: +// - pgdog_rustc_version() → returns compiler version used +// - pgdog_pg_query_version() → returns pg_query version +// - pgdog_plugin_version() → returns plugin version from Cargo.toml + +#[macros::init] +// Wraps user function into pgdog_init() FFI function +// Executed once at plugin load time (synchronous) + +#[macros::route] +// Wraps user function into pgdog_route() FFI function +// Executed for every query (can run on any thread) + +#[macros::fini] +// Wraps user function into pgdog_fini() FFI function +// Executed once at PgDog shutdown (synchronous) +``` + +#### 3. **pgdog-plugin-build** - Build-Time Helpers +- **Location**: `pgdog-plugin-build/src/lib.rs` +- **Role**: Provides `pg_query_version()` function for plugin build scripts +- **Purpose**: Extracts exact `pg_query` version from plugin's `Cargo.toml` and sets `PGDOG_PGQUERY_VERSION` environment variable, ensuring PgDog and plugin use identical versions + +#### 4. **pgdog** - Plugin Runtime Manager +- **Location**: `pgdog/src/plugin/mod.rs` +- **Role**: Main plugin loader and lifecycle manager +- **Key Functions**: + - `load(names: &[&str])` - Load plugins and perform version checks + - `plugins()` - Retrieve loaded plugins + - `shutdown()` - Call plugin cleanup routines + +## Execution Workflow + +### Phase 1: Startup & Plugin Loading + +``` +PgDog starts + ↓ +Read configuration (pgdog.toml) + ↓ +load_from_config() + ├─ Extract plugin names from config.plugins[] + └─ Call load() + ↓ + For each plugin: + ├─ Plugin::library(name) + │ └─ Use libloading to dlopen() shared library + │ + ├─ Plugin::load(name, &lib) + │ ├─ Resolve pgdog_init symbol + │ ├─ Resolve pgdog_fini symbol + │ ├─ Resolve pgdog_route symbol + │ ├─ Resolve pgdog_rustc_version symbol + │ └─ Resolve pgdog_plugin_version symbol + │ + ├─ VERSION CHECKS + │ ├─ Get PgDog's rustc version via comp::rustc_version() + │ ├─ Get plugin's rustc version via pgdog_rustc_version() + │ ├─ If mismatch: WARN and SKIP plugin + │ └─ If match: Continue + │ + ├─ plugin.init() + │ └─ Call pgdog_init() if defined (synchronous) + │ + └─ Log: "loaded {name} plugin (v{version}) [timing]ms" + +Store plugins in static PLUGINS (OnceCell) + ↓ +PgDog ready to serve connections +``` + +**Location**: `pgdog/src/plugin/mod.rs` lines 15-85 + +### Phase 2: Query Routing (Per Query) + +``` +Client sends query + ↓ +PostgreSQL Frontend Parser + ├─ Tokenize and parse query + └─ Generate pg_query AST + ↓ +Query Router: QueryParser::parse() + ├─ Create RouterContext (shards, replicas, etc.) + ├─ Generate PdRouterContext for plugins + │ └─ context.plugin_context(ast, bind_params) + │ ├─ Extract AST from pg_query ParseResult + │ ├─ Pack bind parameters into PdParameters + │ └─ Include cluster metadata (shards, replicas, transaction state) + │ + ├─ Call QueryParser::plugins() + │ └─ For each loaded plugin: + │ ├─ plugin.route(PdRouterContext) + │ │ └─ Call pgdog_route(context, &mut output) + │ │ + │ ├─ Parse returned PdRoute: + │ │ ├─ Shard::Direct(n) → override shard to n + │ │ ├─ Shard::All → broadcast to all shards + │ │ ├─ Shard::Unknown → use default routing + │ │ ├─ Shard::Blocked → block query + │ │ ├─ ReadWrite::Read → send to replica + │ │ ├─ ReadWrite::Write → send to primary + │ │ └─ ReadWrite::Unknown → use default logic + │ │ + │ ├─ Record plugin override (if any) + │ └─ If route provided: BREAK (first plugin wins) + │ + ├─ Apply plugin overrides to routing decision + └─ Continue with default routing logic + ↓ +Select target shard/replica + ↓ +Execute on database +``` + +**Locations**: +- Context creation: `pgdog/src/frontend/router/parser/context.rs` lines 107-140 +- Plugin execution: `pgdog/src/frontend/router/parser/query/plugins.rs` lines 20-105 + +### Phase 3: Shutdown + +``` +PgDog receives shutdown signal + ↓ +Call plugin::shutdown() + └─ For each loaded plugin: + ├─ plugin.fini() + │ └─ Call pgdog_fini() if defined (synchronous) + └─ Log: "plugin {name} shutdown" + ↓ +Clean up remaining resources + ↓ +Process exits +``` + +**Location**: `pgdog/src/plugin/mod.rs` lines 77-83 + +## Plugin Development Workflow + +### Step 1: Create Plugin Project + +```bash +cargo init --lib my_plugin +cd my_plugin +``` + +### Step 2: Configure Cargo.toml + +```toml +[package] +name = "my_plugin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["rlib", "cdylib"] # Critical: enables C ABI shared library + +[dependencies] +pgdog-plugin = "0.1.8" + +[build-dependencies] +pgdog-plugin-build = "0.1.0" +``` + +### Step 3: Create build.rs + +```rust +// build.rs +fn main() { + pgdog_plugin_build::pg_query_version(); +} +``` + +This extracts the exact `pg_query` version from your `Cargo.toml` and sets it as environment variable for runtime version checking. + +### Step 4: Implement Plugin Functions + +```rust +// src/lib.rs +use pgdog_plugin::prelude::*; +use pgdog_macros as macros; + +// Generate required FFI functions +macros::plugin!(); + +// Called once at plugin load time (blocking) +#[macros::init] +fn init() { + // Initialize state, load config, set up synchronization, etc. + println!("Plugin initialized"); +} + +// Called for every query (non-blocking, runs on async executor) +#[macros::route] +fn route(context: Context) -> Route { + // Access query information: + let ast = context.statement().protobuf(); // pg_query AST + let params = context.parameters(); // Prepared statement params + let shards = context.shards(); // Number of shards + let has_replicas = context.has_replicas(); // Cluster has replicas? + let in_transaction = context.in_transaction(); // Connection in transaction? + + // Implement custom routing logic + + // Return routing decision + Route::new(Shard::Unknown, ReadWrite::Unknown) // or any other shard/ro combo +} + +// Called once at PgDog shutdown (blocking) +#[macros::fini] +fn shutdown() { + // Cleanup, flush stats, close connections, etc. + println!("Plugin shutting down"); +} +``` + +### Step 5: Build Plugin + +```bash +# Development +cargo build + +# Release (recommended for production) +cargo build --release +``` + +Build output: +- **Debug**: `target/debug/libmy_plugin.so` (Linux), `.dylib` (macOS) +- **Release**: `target/release/libmy_plugin.so` + +### Step 6: Deploy Plugin + +Choose one of three methods: + +**Method 1: System Library Path** +```bash +cp target/release/libmy_plugin.so /usr/lib/ +``` + +**Method 2: Environment Variable** +```bash +export LD_LIBRARY_PATH=/path/to/plugin/target/release:$LD_LIBRARY_PATH +pgdog --config pgdog.toml +``` + +**Method 3: pgdog.toml Configuration** +```toml +[[plugins]] +name = "my_plugin" # If in system/LD_LIBRARY_PATH +# or +name = "libmy_plugin.so" # Relative path +# or +name = "/usr/lib/libmy_plugin.so" # Absolute path +``` + +### Step 7: Verify Plugin Loading + +Check PgDog startup logs: + +``` +INFO: 🐕 PgDog v0.1.29 (rustc 1.89.0) +INFO: loaded "my_plugin" plugin (v0.1.0) [2.3456ms] +``` + +## Integration Points + +### Router Context Creation + +**File**: `pgdog/src/frontend/router/parser/context.rs` lines 107-140 + +When a query arrives, the router creates a `PdRouterContext` for plugins: + +```rust +pub struct PdRouterContext { + shards: u64, // Number of shards in cluster + has_replicas: u64, // Cluster has replicas (0 or 1) + has_primary: u64, // Cluster has primary (0 or 1) + in_transaction: u64, // Connection in transaction (0 or 1) + query: PdStatement, // Parsed AST from pg_query + write_override: u64, // Write intent (0=read, 1=write) + params: PdParameters, // Bind parameters (if prepared statement) +} +``` + +The AST is the pg_query protobuf structure: + +```rust +pub struct ParseResult { + pub version: u32, + pub stmts: Vec, // Array of statements +} + +pub struct RawStmt { + pub stmt: Option, +} + +pub enum NodeEnum { + SelectStmt(...), + InsertStmt(...), + UpdateStmt(...), + DeleteStmt(...), + // ... many more SQL statement types +} +``` + +### Plugin Execution Point + +**File**: `pgdog/src/frontend/router/parser/query/plugins.rs` lines 20-105 + +The query parser invokes plugins after parsing but before applying default routing logic: + +```rust +pub(super) fn plugins( + &mut self, + context: &QueryParserContext, + statement: &Ast, + read: bool, +) -> Result<(), Error> { + // Skip if no plugins loaded + let plugins = if let Some(plugins) = crate::plugin::plugins() { + plugins + } else { + return Ok(()); + }; + + // Create context for plugins + let mut context = context.plugin_context( + &statement.parse_result().protobuf, + &context.router_context.bind, + ); + + // Try each plugin in order + for plugin in plugins { + if let Some(route) = plugin.route(context) { + // First plugin to return a route wins + // Parse and apply the route + self.plugin_output.shard = Some(Shard::Direct(route.shard)); + self.plugin_output.read = Some(route.read_write); + break; // Stop processing plugins + } + } + + Ok(()) +} +``` + +### Configuration Loading + +**File**: `pgdog-config/src/users.rs` lines 1-20 + +Plugin names are loaded from `pgdog.toml`: + +```rust +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Plugin { + pub name: String, // Plugin library name or path +} +``` + +The `name` field can be: +- Library name (will search system paths + `LD_LIBRARY_PATH`) +- Relative path to `.so`/`.dylib` +- Absolute path to shared library + +## Safety & Compatibility + +### Rust Compiler Version Checking + +PgDog enforces that plugins are compiled with the exact same Rust compiler version as PgDog itself. + +**How it works**: + +1. At PgDog build time: + - `pgdog-plugin` calls `comp::rustc_version()` which uses `rustc --version` + - This version is embedded via the `plugin!()` macro + +2. At plugin build time: + - The `plugin!()` macro generates `pgdog_rustc_version()` function + - This function returns the compiler version used to build the plugin + +3. At plugin load time: + - PgDog calls both functions and compares versions + - If mismatch: **plugin is skipped** with warning + - If match: plugin is loaded and initialized + +**Why**: Rust doesn't have a stable ABI. Memory layouts of types can change between compiler versions. Using mismatched versions could lead to undefined behavior. + +### pg_query Version Matching + +Since plugins receive `pg_query` AST types directly via FFI, they must use the exact same version as PgDog. + +**How it works**: + +1. In plugin's `build.rs`: + - `pgdog_plugin_build::pg_query_version()` reads `Cargo.toml` + - Extracts version constraint on `pg_query` (e.g., `"6.1.0"`) + - Sets `PGDOG_PGQUERY_VERSION` environment variable + +2. In plugin's `src/lib.rs`: + - The `plugin!()` macro generates `pgdog_pg_query_version()` function + - This function returns the version from environment variable + +3. At plugin load time: + - PgDog can verify version match (currently informational) + +**Best Practice**: Always use the `pgdog-plugin` re-exports: + +```rust +// Good: uses pgdog-plugin's pg_query version +use pgdog_plugin::prelude::*; // includes pg_query +use pgdog_plugin::pg_query; + +// Bad: independently using pg_query +use pg_query; // Version mismatch risk! +``` + +### FFI Safety + +The `pgdog-plugin` crate handles FFI safety by: + +1. **Wrapping plugin context**: `PdRouterContext` → `Context` + - Only exposes safe methods that validate input + +2. **Wrapping plugin output**: `Route` → `PdRoute` + - Converts Rust types to C-compatible structures + +3. **Symbol resolution**: Uses `libloading::Symbol` with correct type signatures + - Type mismatch = load failure (not UB) + +4. **Lifetime management**: FFI pointers are valid only during function call + - Context is created on stack, passed by reference, cleaned up after + +## Advanced Topics + +### Variables Available in Plugin Context + +From `Context` struct: + +```rust +impl Context { + /// Number of configured shards + pub fn shards(&self) -> u64 + + /// Does cluster have readable replicas? + pub fn has_replicas(&self) -> bool + + /// Does cluster have writable primary? + pub fn has_primary(&self) -> bool + + /// Is this connection in a transaction? + pub fn in_transaction(&self) -> bool + + /// Parsed query AST from pg_query + pub fn statement(&self) -> Statement + + /// Prepared statement parameters (if any) + pub fn parameters(&self) -> Parameters + + /// Force query as READ even if normally a write + pub fn write_override(&self) -> bool +} +``` + +### Plugin Output Options + +```rust +impl Route { + /// No routing decision + pub fn unknown() -> Self + + /// Block query from executing + pub fn block() -> Self + + /// Direct to specific shard + pub fn direct(shard: u64) -> Self + + /// Broadcast to all shards + pub fn all() -> Self + + /// Specify shard and read/write + pub fn new(shard: Shard, read_write: ReadWrite) -> Self +} + +pub enum Shard { + Direct(u64), // Specific shard index + All, // All shards + Unknown, // Use default routing + Blocked, // Block query +} + +pub enum ReadWrite { + Read, // Send to replica + Write, // Send to primary + Unknown, // Use default logic +} +``` + +### Accessing Query Parameters + +For prepared statements, access bind parameters: + +```rust +#[macros::route] +fn route(context: Context) -> Route { + let params = context.parameters(); + + // Get count of parameters + let count = params.count(); + + // Get specific parameter by index (0-based) + if let Some(param) = params.get(0) { + // Decode based on format + if let Some(value) = param.decode(params.parameter_format(0)) { + match value { + ParameterValue::Text(s) => println!("String: {}", s), + ParameterValue::Int(i) => println!("Int: {}", i), + ParameterValue::Query(q) => println!("Query: {}", q), + // ... other types + } + } + } + + Route::unknown() +} +``` + +### Query Blocking Example + +Block queries that don't follow security requirements: + +```rust +#[macros::route] +fn route(context: Context) -> Route { + let ast = context.statement().protobuf(); + + // Block INSERT without explicit column list + if let Some(raw_stmt) = ast.stmts.first() { + if let Some(stmt) = &raw_stmt.stmt { + if let Some(NodeEnum::InsertStmt(insert)) = &stmt.node { + if insert.cols.is_empty() { + return Route::block(); // Block it! + } + } + } + } + + Route::unknown() +} +``` + +### Logging from Plugins + +Use `eprintln!()` or standard Rust logging (routes to stderr): + +```rust +#[macros::route] +fn route(context: Context) -> Route { + eprintln!("Processing query with {} params", context.parameters().count()); + + Route::unknown() +} +``` + +## Performance Considerations + +1. **Plugin initialization is synchronous**: Keep `init()` fast or it blocks PgDog startup +2. **Plugin routes run on async executor**: Can do async work, but not thread-blocking operations +3. **First-match semantics**: Plugins are checked in order; earlier plugins take precedence +4. **No error handling**: Plugin functions cannot return errors or panic + - Must return default `Route::unknown()` in error cases +5. **AST parsing is done before plugins**: Plugins can't change query parsing, only routing + +## Debugging Plugins + +### Check compiler version match: + +```bash +# PgDog shows at startup: +# INFO: 🐕 PgDog v0.1.29 (rustc 1.89.0 (29483883e 2025-08-04)) + +# Update your plugin's Rust: +rustup update +rustc --version +# rustc 1.89.0 (29483883e 2025-08-04) + +# Rebuild plugin: +cargo clean && cargo build --release +``` + +### Check plugin loads: + +```bash +# In PgDog logs, should see: +INFO: loaded "my_plugin" plugin (v0.1.0) [X.XXXXms] + +# If plugin is skipped: +WARN: skipping plugin "my_plugin" because it was compiled with different compiler version +WARN: skipping plugin "my_plugin" because it doesn't expose its Rust compiler version +``` + +### Library not found: + +```bash +# Verify library exists: +ldd /usr/lib/libmy_plugin.so # Linux +otool -L /usr/local/lib/libmy_plugin.dylib # macOS + +# Check LD_LIBRARY_PATH: +echo $LD_LIBRARY_PATH + +# Verify symbols: +nm -D /usr/lib/libmy_plugin.so | grep pgdog_ +``` + +## Example: Tenant-Based Routing Plugin + +```rust +use pgdog_plugin::prelude::*; +use pgdog_macros as macros; + +macros::plugin!(); + +#[macros::init] +fn init() { + eprintln!("Tenant routing plugin initialized"); +} + +#[macros::route] +fn route(context: Context) -> Route { + let params = context.parameters(); + + // Extract tenant_id from function parameter + if let Some(param) = params.get(0) { + if let Some(ParameterValue::Int(tenant_id)) = param.decode(ParameterFormat::Text) { + // Route to shard based on tenant_id + // Assuming simple hash modulo sharding + let shard = (tenant_id as u64) % context.shards(); + return Route::direct(shard); + } + } + + // No tenant_id found, use default routing + Route::unknown() +} + +#[macros::fini] +fn shutdown() { + eprintln!("Tenant routing plugin shutting down"); +} +``` + +## Testing + +### Integration Tests + +**Location**: [integration/plugins/](../integration/plugins/) + +**Main test suite**: [integration/plugins/extended_spec.rb](../integration/plugins/extended_spec.rb) + +The integration test validates the FFI parameter passing mechanism by: +- Executing parameterized queries (`SELECT ... WHERE col = $1`) through PgDog with the plugin loaded +- Testing 10 separate connections, each executing 25 prepared statements with different parameter values +- Verifying that parameter values correctly pass through the FFI boundary to the plugin +- Ensuring query results match the input parameters + +**What it validates**: +- Prepared statement parameters pass correctly through FFI +- Plugin receives and can decode parameter values +- Extended protocol (parameterized queries) works with plugins +- Multiple connections and queries function correctly +- Query execution continues despite plugin intervention +- The example plugin's parameter logging functionality + +**Configuration**: [integration/plugins/pgdog.toml](../integration/plugins/pgdog.toml) configures PgDog to load the example plugin + +**How to run**: +```bash +cd integration/plugins +bash run.sh +``` + +**Note**: This test is NOT part of the default integration test suite ([integration/run.sh](../integration/run.sh)). + +### Unit Tests + +#### Plugin Data Structures + +**Location**: [pgdog-plugin/src/](../pgdog-plugin/src/) + +**Test modules**: +- [parameters.rs#L238](../pgdog-plugin/src/parameters.rs#L238) - Tests parameter FFI structure creation and empty parameter handling +- [ast.rs#L108](../pgdog-plugin/src/ast.rs#L108) - Tests AST FFI conversion by parsing SQL and verifying the protobuf structure is correctly accessible through FFI +- [string.rs#L69](../pgdog-plugin/src/string.rs#L69) - Tests PdStr conversions from both `&str` and `String` types + +**Testing approach**: These unit tests validate that FFI data structures correctly wrap and expose Rust types without memory corruption or lifetime issues. + +**How to run**: +```bash +cd pgdog-plugin +cargo test +``` + +#### Example Plugin Tests + +**Location**: [plugins/pgdog-example-plugin/src/plugin.rs#L104-136](../plugins/pgdog-example-plugin/src/plugin.rs#L104-136) + +**Test approach**: +- Manually constructs a `PdRouterContext` with a parsed `SELECT` query +- Calls the plugin's `route_query()` function directly +- Verifies the routing decision matches expected behavior (Read route for SELECT on replica-enabled cluster) + +This demonstrates how to unit test plugin routing logic without running the full PgDog proxy. + +### Test Coverage Gaps + +The following areas lack test coverage: + +#### Plugin Loading & Lifecycle +- ❌ Rust compiler version mismatch scenarios +- ❌ pg_query version verification (currently not implemented) +- ❌ Plugin symbol resolution failures +- ❌ Plugin with missing required functions +- ❌ Plugin init/fini execution order +- ❌ Plugin initialization failures +- ❌ Library loading errors (bad path, missing dependencies) + +#### Routing Behavior +- ❌ `Route::direct(n)` - routing to specific shard +- ❌ `Route::all()` - broadcasting to all shards +- ❌ `Route::block()` - blocking query execution +- ❌ Multiple plugins with precedence rules +- ❌ Plugin routing overrides (shard + read/write combinations) +- ❌ Read/write routing decisions + +#### Error Handling +- ❌ Plugin panics (should not crash PgDog) +- ❌ Invalid route values +- ❌ Malformed plugin libraries +- ❌ ABI violations + +#### Concurrency +- ❌ Concurrent route() calls from multiple threads +- ❌ Thread safety of plugin state +- ❌ Race conditions in plugin initialization + + +## Related Documentation + +- [PgDog Plugin Documentation](https://docs.pgdog.dev/features/plugins/) +- [pgdog-plugin Rust Docs](https://docsrs.pgdog.dev/pgdog_plugin/) +- [pg_query Rust Docs](https://docsrs.pgdog.dev/pg_query/) +- [Query Router Configuration](https://docs.pgdog.dev/configuration/pgdog.toml/plugins/) From ffa666be1e3e50fd8a4649eb7525043d4ef25c4e Mon Sep 17 00:00:00 2001 From: meskill <8974488+meskill@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:25:26 +0000 Subject: [PATCH 2/4] fix(pgdog-plugin): api version check --- .gitignore | 1 + Cargo.lock | 2 +- Cargo.toml | 3 + docs/PLUGIN_SYSTEM.md | 800 ++++-------------- integration/plugins/.gitignore | 1 + integration/plugins/extended_spec.rb | 13 + integration/plugins/pgdog.toml | 9 + integration/plugins/run.sh | 18 +- .../test-plugins/assertions/Cargo.toml | 6 + .../test-plugins/assertions/src/lib.rs | 23 + .../test-plugin-compatible/Cargo.toml | 13 + .../test-plugin-compatible/src/lib.rs | 24 + .../test-plugins/test-plugin-main/Cargo.toml | 13 + .../test-plugins/test-plugin-main/src/lib.rs | 14 + .../test-plugin-outdated/Cargo.toml | 12 + .../test-plugin-outdated/src/lib.rs | 13 + pgdog-macros/src/lib.rs | 8 + pgdog-plugin/Cargo.toml | 2 +- pgdog-plugin/src/comp.rs | 5 + pgdog-plugin/src/context.rs | 1 + pgdog-plugin/src/plugin.rs | 15 + pgdog/Cargo.toml | 2 +- pgdog/src/plugin/mod.rs | 25 + pgdog/src/util.rs | 3 +- plugins/pgdog-example-plugin/Cargo.toml | 2 +- 25 files changed, 366 insertions(+), 662 deletions(-) create mode 100644 integration/plugins/.gitignore create mode 100644 integration/plugins/test-plugins/assertions/Cargo.toml create mode 100644 integration/plugins/test-plugins/assertions/src/lib.rs create mode 100644 integration/plugins/test-plugins/test-plugin-compatible/Cargo.toml create mode 100644 integration/plugins/test-plugins/test-plugin-compatible/src/lib.rs create mode 100644 integration/plugins/test-plugins/test-plugin-main/Cargo.toml create mode 100644 integration/plugins/test-plugins/test-plugin-main/src/lib.rs create mode 100644 integration/plugins/test-plugins/test-plugin-outdated/Cargo.toml create mode 100644 integration/plugins/test-plugins/test-plugin-outdated/src/lib.rs diff --git a/.gitignore b/.gitignore index 2988b3428..e5a9d122f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ integration/log.txt integration/pgdog.config integration/**/.bundle integration/**/vendor +integration/**/*.test diff --git a/Cargo.lock b/Cargo.lock index 9b9961483..322caaead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "pgdog-plugin" -version = "0.1.8" +version = "0.2.0" dependencies = [ "bindgen 0.71.1", "libc", diff --git a/Cargo.toml b/Cargo.toml index 4d87a8543..6a58c7a96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,6 @@ inherits = "release" lto = false codegen-units = 16 debug = true + +[workspace.dependencies] +pgdog-plugin = { path = "./pgdog-plugin", version = "0.2.0" } diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md index e46ed8845..2e695685f 100644 --- a/docs/PLUGIN_SYSTEM.md +++ b/docs/PLUGIN_SYSTEM.md @@ -1,691 +1,189 @@ -# PgDog Plugin System - Architecture & Execution Workflow +# PgDog Plugin System - Overview & Essentials -This document provides a comprehensive overview of PgDog's plugin system, including its architecture, execution flow, integration points, and development guidelines. +This document summarizes PgDog's plugin system: architecture, execution flow, integration points, and key development guidelines. For full details, see the referenced source files. -## Overview - -PgDog's plugin system enables dynamic, runtime customization of query routing behavior through Rust-based plugins compiled as shared libraries. The system uses FFI (Foreign Function Interface) to safely communicate between PgDog and plugins while maintaining strict version compatibility guarantees. +PgDog plugins are Rust shared libraries loaded at runtime, allowing custom query routing. FFI is used for safe communication, with strict version checks for safety. ## Architecture -### Core Components - -PgDog's plugin system is built on four main crates: - -#### 1. **pgdog-plugin** - Plugin Interface Bridge -- **Location**: `pgdog-plugin/` -- **Role**: Main user-facing plugin library -- **Key Modules**: - - `plugin.rs` - Plugin lifecycle and FFI symbol loading via `libloading` - - `context.rs` - `Context` and `Route` types for plugin I/O - - `parameters.rs` - Prepared statement parameter handling - - `bindings.rs` - Auto-generated C ABI bindings via bindgen - - `prelude.rs` - Common imports (macros, types, functions) - -**Key Responsibilities**: -- Provides safe Rust interfaces for plugin development -- Manages FFI symbol resolution and execution -- Performs automatic version compatibility checks -- Re-exports `pg_query` with guaranteed version matching - -#### 2. **pgdog-macros** - Plugin Code Generation -- **Location**: `pgdog-macros/src/lib.rs` -- **Purpose**: Procedural macros that auto-generate required FFI functions - -**Exported Macros**: -```rust -macros::plugin!() -// Generates three required FFI functions: -// - pgdog_rustc_version() → returns compiler version used -// - pgdog_pg_query_version() → returns pg_query version -// - pgdog_plugin_version() → returns plugin version from Cargo.toml - -#[macros::init] -// Wraps user function into pgdog_init() FFI function -// Executed once at plugin load time (synchronous) - -#[macros::route] -// Wraps user function into pgdog_route() FFI function -// Executed for every query (can run on any thread) - -#[macros::fini] -// Wraps user function into pgdog_fini() FFI function -// Executed once at PgDog shutdown (synchronous) -``` - -#### 3. **pgdog-plugin-build** - Build-Time Helpers -- **Location**: `pgdog-plugin-build/src/lib.rs` -- **Role**: Provides `pg_query_version()` function for plugin build scripts -- **Purpose**: Extracts exact `pg_query` version from plugin's `Cargo.toml` and sets `PGDOG_PGQUERY_VERSION` environment variable, ensuring PgDog and plugin use identical versions +PgDog's plugin system consists of four main crates: -#### 4. **pgdog** - Plugin Runtime Manager -- **Location**: `pgdog/src/plugin/mod.rs` -- **Role**: Main plugin loader and lifecycle manager -- **Key Functions**: - - `load(names: &[&str])` - Load plugins and perform version checks - - `plugins()` - Retrieve loaded plugins - - `shutdown()` - Call plugin cleanup routines +- **pgdog-plugin**: User-facing plugin interface ([pgdog-plugin/](../pgdog-plugin)). Provides safe Rust APIs, FFI symbol management, and version checks. +- **pgdog-macros**: Procedural macros for generating required FFI functions (`plugin!`, `init`, `route`, `fini`). See [pgdog-macros/src/lib.rs](../pgdog-macros/src/lib.rs). +- **pgdog-plugin-build**: Build-time helpers for version pinning. See [pgdog-plugin-build/src/lib.rs](../pgdog-plugin-build/src/lib.rs). +- **pgdog**: Plugin loader and runtime manager. See [pgdog/src/plugin/mod.rs](../pgdog/src/plugin/mod.rs). ## Execution Workflow +**Startup:** + ### Phase 1: Startup & Plugin Loading ``` PgDog starts - ↓ + ↓ Read configuration (pgdog.toml) - ↓ + ↓ load_from_config() - ├─ Extract plugin names from config.plugins[] - └─ Call load() - ↓ - For each plugin: - ├─ Plugin::library(name) - │ └─ Use libloading to dlopen() shared library - │ - ├─ Plugin::load(name, &lib) - │ ├─ Resolve pgdog_init symbol - │ ├─ Resolve pgdog_fini symbol - │ ├─ Resolve pgdog_route symbol - │ ├─ Resolve pgdog_rustc_version symbol - │ └─ Resolve pgdog_plugin_version symbol - │ - ├─ VERSION CHECKS - │ ├─ Get PgDog's rustc version via comp::rustc_version() - │ ├─ Get plugin's rustc version via pgdog_rustc_version() - │ ├─ If mismatch: WARN and SKIP plugin - │ └─ If match: Continue - │ - ├─ plugin.init() - │ └─ Call pgdog_init() if defined (synchronous) - │ - └─ Log: "loaded {name} plugin (v{version}) [timing]ms" + ├─ Extract plugin names from config.plugins[] + └─ Call load() + ↓ + For each plugin: + ├─ Plugin::library(name) + │ └─ Use libloading to dlopen() shared library + │ + ├─ Plugin::load(name, &lib) + │ ├─ Resolve pgdog_init symbol + │ ├─ Resolve pgdog_fini symbol + │ ├─ Resolve pgdog_route symbol + │ ├─ Resolve pgdog_rustc_version symbol + │ └─ Resolve pgdog_plugin_version symbol + │ + ├─ VERSION CHECKS + │ ├─ Get PgDog's rustc version via comp::rustc_version() + │ ├─ Get plugin's rustc version via pgdog_rustc_version() + │ ├─ If mismatch: WARN and SKIP plugin + │ └─ If match: Continue + │ + ├─ plugin.init() + │ └─ Call pgdog_init() if defined (synchronous) + │ + └─ Log: "loaded {name} plugin (v{version}) [timing]ms" Store plugins in static PLUGINS (OnceCell) - ↓ + ↓ PgDog ready to serve connections ``` -**Location**: `pgdog/src/plugin/mod.rs` lines 15-85 +**Location**: [pgdog/src/plugin/mod.rs](../pgdog/src/plugin/mod.rs) lines 15-85 ### Phase 2: Query Routing (Per Query) ``` Client sends query - ↓ + ↓ PostgreSQL Frontend Parser - ├─ Tokenize and parse query - └─ Generate pg_query AST - ↓ + ├─ Tokenize and parse query + └─ Generate pg_query AST + ↓ Query Router: QueryParser::parse() - ├─ Create RouterContext (shards, replicas, etc.) - ├─ Generate PdRouterContext for plugins - │ └─ context.plugin_context(ast, bind_params) - │ ├─ Extract AST from pg_query ParseResult - │ ├─ Pack bind parameters into PdParameters - │ └─ Include cluster metadata (shards, replicas, transaction state) - │ - ├─ Call QueryParser::plugins() - │ └─ For each loaded plugin: - │ ├─ plugin.route(PdRouterContext) - │ │ └─ Call pgdog_route(context, &mut output) - │ │ - │ ├─ Parse returned PdRoute: - │ │ ├─ Shard::Direct(n) → override shard to n - │ │ ├─ Shard::All → broadcast to all shards - │ │ ├─ Shard::Unknown → use default routing - │ │ ├─ Shard::Blocked → block query - │ │ ├─ ReadWrite::Read → send to replica - │ │ ├─ ReadWrite::Write → send to primary - │ │ └─ ReadWrite::Unknown → use default logic - │ │ - │ ├─ Record plugin override (if any) - │ └─ If route provided: BREAK (first plugin wins) - │ - ├─ Apply plugin overrides to routing decision - └─ Continue with default routing logic - ↓ + ├─ Create RouterContext (shards, replicas, etc.) + ├─ Generate PdRouterContext for plugins + │ └─ context.plugin_context(ast, bind_params) + │ ├─ Extract AST from pg_query ParseResult + │ ├─ Pack bind parameters into PdParameters + │ └─ Include cluster metadata (shards, replicas, transaction state) + │ + ├─ Call QueryParser::plugins() + │ └─ For each loaded plugin: + │ ├─ plugin.route(PdRouterContext) + │ │ └─ Call pgdog_route(context, &mut output) + │ │ + │ ├─ Parse returned PdRoute: + │ │ ├─ Shard::Direct(n) → override shard to n + │ │ ├─ Shard::All → broadcast to all shards + │ │ ├─ Shard::Unknown → use default routing + │ │ ├─ Shard::Blocked → block query + │ │ ├─ ReadWrite::Read → send to replica + │ │ ├─ ReadWrite::Write → send to primary + │ │ └─ ReadWrite::Unknown → use default logic + │ │ + │ ├─ Record plugin override (if any) + │ └─ If route provided: BREAK (first plugin wins) + │ + ├─ Apply plugin overrides to routing decision + └─ Continue with default routing logic + ↓ Select target shard/replica - ↓ + ↓ Execute on database ``` **Locations**: -- Context creation: `pgdog/src/frontend/router/parser/context.rs` lines 107-140 -- Plugin execution: `pgdog/src/frontend/router/parser/query/plugins.rs` lines 20-105 + - Context creation: [pgdog/src/frontend/router/parser/context.rs](../pgdog/src/frontend/router/parser/context.rs) lines 107-140 + - Plugin execution: [pgdog/src/frontend/router/parser/query/plugins.rs](../pgdog/src/frontend/router/parser/query/plugins.rs) lines 20-105 ### Phase 3: Shutdown ``` PgDog receives shutdown signal - ↓ + ↓ Call plugin::shutdown() - └─ For each loaded plugin: - ├─ plugin.fini() - │ └─ Call pgdog_fini() if defined (synchronous) - └─ Log: "plugin {name} shutdown" - ↓ + └─ For each loaded plugin: + ├─ plugin.fini() + │ └─ Call pgdog_fini() if defined (synchronous) + └─ Log: "plugin {name} shutdown" + ↓ Clean up remaining resources - ↓ + ↓ Process exits ``` **Location**: `pgdog/src/plugin/mod.rs` lines 77-83 -## Plugin Development Workflow - -### Step 1: Create Plugin Project - -```bash -cargo init --lib my_plugin -cd my_plugin -``` - -### Step 2: Configure Cargo.toml - -```toml -[package] -name = "my_plugin" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["rlib", "cdylib"] # Critical: enables C ABI shared library - -[dependencies] -pgdog-plugin = "0.1.8" +## Plugin Development -[build-dependencies] -pgdog-plugin-build = "0.1.0" -``` - -### Step 3: Create build.rs - -```rust -// build.rs -fn main() { - pgdog_plugin_build::pg_query_version(); -} -``` +1. **Create a Rust library** with `crate-type = ["cdylib"]` in `Cargo.toml`. +2. **Add dependencies:** `pgdog-plugin` and `pgdog-plugin-build`. +3. **Use macros:** Annotate your functions with `plugin!`, `init`, `route`, and `fini` macros. See `pgdog-macros` for details. +4. **Build:** `cargo build --release` produces a `.so`/`.dylib` file. +5. **Deploy:** Place the shared library in a system path, set `LD_LIBRARY_PATH`, or reference it in `pgdog.toml`. +6. **Verify:** Check PgDog logs for successful plugin loading. -This extracts the exact `pg_query` version from your `Cargo.toml` and sets it as environment variable for runtime version checking. - -### Step 4: Implement Plugin Functions - -```rust -// src/lib.rs -use pgdog_plugin::prelude::*; -use pgdog_macros as macros; - -// Generate required FFI functions -macros::plugin!(); - -// Called once at plugin load time (blocking) -#[macros::init] -fn init() { - // Initialize state, load config, set up synchronization, etc. - println!("Plugin initialized"); -} - -// Called for every query (non-blocking, runs on async executor) -#[macros::route] -fn route(context: Context) -> Route { - // Access query information: - let ast = context.statement().protobuf(); // pg_query AST - let params = context.parameters(); // Prepared statement params - let shards = context.shards(); // Number of shards - let has_replicas = context.has_replicas(); // Cluster has replicas? - let in_transaction = context.in_transaction(); // Connection in transaction? - - // Implement custom routing logic - - // Return routing decision - Route::new(Shard::Unknown, ReadWrite::Unknown) // or any other shard/ro combo -} - -// Called once at PgDog shutdown (blocking) -#[macros::fini] -fn shutdown() { - // Cleanup, flush stats, close connections, etc. - println!("Plugin shutting down"); -} -``` - -### Step 5: Build Plugin - -```bash -# Development -cargo build - -# Release (recommended for production) -cargo build --release -``` - -Build output: -- **Debug**: `target/debug/libmy_plugin.so` (Linux), `.dylib` (macOS) -- **Release**: `target/release/libmy_plugin.so` - -### Step 6: Deploy Plugin - -Choose one of three methods: - -**Method 1: System Library Path** -```bash -cp target/release/libmy_plugin.so /usr/lib/ -``` - -**Method 2: Environment Variable** -```bash -export LD_LIBRARY_PATH=/path/to/plugin/target/release:$LD_LIBRARY_PATH -pgdog --config pgdog.toml -``` - -**Method 3: pgdog.toml Configuration** -```toml -[[plugins]] -name = "my_plugin" # If in system/LD_LIBRARY_PATH -# or -name = "libmy_plugin.so" # Relative path -# or -name = "/usr/lib/libmy_plugin.so" # Absolute path -``` - -### Step 7: Verify Plugin Loading - -Check PgDog startup logs: - -``` -INFO: 🐕 PgDog v0.1.29 (rustc 1.89.0) -INFO: loaded "my_plugin" plugin (v0.1.0) [2.3456ms] -``` +See the [plugins/pgdog-example-plugin/](../plugins/pgdog-example-plugin/) for a full example. ## Integration Points -### Router Context Creation - -**File**: `pgdog/src/frontend/router/parser/context.rs` lines 107-140 - -When a query arrives, the router creates a `PdRouterContext` for plugins: - -```rust -pub struct PdRouterContext { - shards: u64, // Number of shards in cluster - has_replicas: u64, // Cluster has replicas (0 or 1) - has_primary: u64, // Cluster has primary (0 or 1) - in_transaction: u64, // Connection in transaction (0 or 1) - query: PdStatement, // Parsed AST from pg_query - write_override: u64, // Write intent (0=read, 1=write) - params: PdParameters, // Bind parameters (if prepared statement) -} -``` - -The AST is the pg_query protobuf structure: - -```rust -pub struct ParseResult { - pub version: u32, - pub stmts: Vec, // Array of statements -} - -pub struct RawStmt { - pub stmt: Option, -} - -pub enum NodeEnum { - SelectStmt(...), - InsertStmt(...), - UpdateStmt(...), - DeleteStmt(...), - // ... many more SQL statement types -} -``` - -### Plugin Execution Point - -**File**: `pgdog/src/frontend/router/parser/query/plugins.rs` lines 20-105 - -The query parser invokes plugins after parsing but before applying default routing logic: - -```rust -pub(super) fn plugins( - &mut self, - context: &QueryParserContext, - statement: &Ast, - read: bool, -) -> Result<(), Error> { - // Skip if no plugins loaded - let plugins = if let Some(plugins) = crate::plugin::plugins() { - plugins - } else { - return Ok(()); - }; - - // Create context for plugins - let mut context = context.plugin_context( - &statement.parse_result().protobuf, - &context.router_context.bind, - ); - - // Try each plugin in order - for plugin in plugins { - if let Some(route) = plugin.route(context) { - // First plugin to return a route wins - // Parse and apply the route - self.plugin_output.shard = Some(Shard::Direct(route.shard)); - self.plugin_output.read = Some(route.read_write); - break; // Stop processing plugins - } - } - - Ok(()) -} -``` - -### Configuration Loading - -**File**: `pgdog-config/src/users.rs` lines 1-20 - -Plugin names are loaded from `pgdog.toml`: - -```rust -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct Plugin { - pub name: String, // Plugin library name or path -} -``` - -The `name` field can be: -- Library name (will search system paths + `LD_LIBRARY_PATH`) -- Relative path to `.so`/`.dylib` -- Absolute path to shared library +- **Context creation:** See [pgdog/src/frontend/router/parser/context.rs](../pgdog/src/frontend/router/parser/context.rs) for how plugin context is built. +- **Plugin execution:** See [pgdog/src/frontend/router/parser/query/plugins.rs](../pgdog/src/frontend/router/parser/query/plugins.rs) for plugin invocation logic. +- **Configuration:** Plugins are listed in `pgdog.toml` (see [pgdog-config/src/users.rs](../pgdog-config/src/users.rs)). The `name` field can be a library name, relative, or absolute path. ## Safety & Compatibility -### Rust Compiler Version Checking - -PgDog enforces that plugins are compiled with the exact same Rust compiler version as PgDog itself. - -**How it works**: +- **Rust version:** Plugins must be built with the exact same Rust compiler version as PgDog. Mismatches are skipped at load time. +- **pg_query version:** Plugins must use the same `pg_query` version as PgDog. Use the re-exports from `pgdog-plugin`. +- **FFI safety:** All FFI types are `#[repr(C)]` and memory is managed to avoid UB. See [pgdog-plugin/src/bindings.rs](../pgdog-plugin/src/bindings.rs) and [pgdog-plugin/src/parameters.rs](../pgdog-plugin/src/parameters.rs). -1. At PgDog build time: - - `pgdog-plugin` calls `comp::rustc_version()` which uses `rustc --version` - - This version is embedded via the `plugin!()` macro +## FFI & ABI Notes -2. At plugin build time: - - The `plugin!()` macro generates `pgdog_rustc_version()` function - - This function returns the compiler version used to build the plugin +- FFI boundary uses `#[repr(C)]` types for ABI safety. See [pgdog-plugin/src/bindings.rs](../pgdog-plugin/src/bindings.rs). +- Plugins reconstruct Rust types from FFI pointers; exact Rust and dependency versions are required. +- The FFI boundary is fragile: plugin and host must use the same Rust and dependency versions, and memory layout must match exactly. Opaque pointers and container reinterpretation (e.g., `Vec::from_raw_parts`) are used, which can break with even minor version or feature changes. See [pgdog-plugin/src/parameters.rs](../pgdog-plugin/src/parameters.rs) and code comments for details. +- Ownership and lifetime: host must keep memory valid for the FFI call; plugins must not free or mutate host-owned memory unless explicitly allowed. Panics must not unwind across the FFI boundary. -3. At plugin load time: - - PgDog calls both functions and compares versions - - If mismatch: **plugin is skipped** with warning - - If match: plugin is loaded and initialized +### Maintainability Issues & Pain Points -**Why**: Rust doesn't have a stable ABI. Memory layouts of types can change between compiler versions. Using mismatched versions could lead to undefined behavior. +- **Opaque pointers hide implementation details:** Pointer fields (e.g., `void*`) obscure the true layout and ownership, so important rules are only in documentation, not enforced by the type system. +- **Fragile container reinterpretation:** Using `Vec::from_raw_parts` and similar tricks relies on *identical* Rust versions, crate versions, and feature flags. Any mismatch can cause undefined behavior or memory corruption. +- **Transitive dependency coupling:** Types like the `pg_query` AST or `bytes::Bytes` add hidden constraints on plugin dependencies, making upgrades and changes risky. +- **Hard to evolve:** Internal representation changes (e.g., switching `Bytes` → `Vec`, changing struct layouts) are breaking and require all plugins to be rebuilt in lockstep. +- **Debugging cost:** ABI or UB problems are subtle, often nondeterministic, and hard to diagnose or reproduce. +- **No ABI versioning:** There is no formal ABI version negotiation; any change in the host or plugin can silently break compatibility. -### pg_query Version Matching +See the original design notes and migration checklist in the source for more details and future directions. +- Future improvements may include a stable C ABI, serialized ASTs, and ABI versioning. See code comments for migration plans. -Since plugins receive `pg_query` AST types directly via FFI, they must use the exact same version as PgDog. +## Plugin Context & Output -**How it works**: - -1. In plugin's `build.rs`: - - `pgdog_plugin_build::pg_query_version()` reads `Cargo.toml` - - Extracts version constraint on `pg_query` (e.g., `"6.1.0"`) - - Sets `PGDOG_PGQUERY_VERSION` environment variable - -2. In plugin's `src/lib.rs`: - - The `plugin!()` macro generates `pgdog_pg_query_version()` function - - This function returns the version from environment variable - -3. At plugin load time: - - PgDog can verify version match (currently informational) - -**Best Practice**: Always use the `pgdog-plugin` re-exports: - -```rust -// Good: uses pgdog-plugin's pg_query version -use pgdog_plugin::prelude::*; // includes pg_query -use pgdog_plugin::pg_query; - -// Bad: independently using pg_query -use pg_query; // Version mismatch risk! -``` +- The plugin `Context` provides access to cluster info, query AST, parameters, and transaction state. See [pgdog-plugin/src/context.rs](../pgdog-plugin/src/context.rs). +- Plugins return a `Route` to control query routing (unknown, block, direct, all, read/write). See [pgdog-plugin/src/plugin.rs](../pgdog-plugin/src/plugin.rs). -### FFI Safety +## Performance & Best Practices -The `pgdog-plugin` crate handles FFI safety by: +- Keep `init()` fast; it blocks startup. +- `route()` runs on async executor; avoid blocking. +- Plugins are checked in order; first to return a route wins. +- Plugins must not panic; always return a valid `Route`. +- Plugins cannot modify query parsing, only routing. -1. **Wrapping plugin context**: `PdRouterContext` → `Context` - - Only exposes safe methods that validate input +## Debugging -2. **Wrapping plugin output**: `Route` → `PdRoute` - - Converts Rust types to C-compatible structures +- Check PgDog logs for plugin load status and version mismatches. +- Use `ldd`/`otool` to verify library paths and symbols if plugins fail to load. -3. **Symbol resolution**: Uses `libloading::Symbol` with correct type signatures - - Type mismatch = load failure (not UB) +## Example -4. **Lifetime management**: FFI pointers are valid only during function call - - Context is created on stack, passed by reference, cleaned up after - -## Advanced Topics - -### Variables Available in Plugin Context - -From `Context` struct: - -```rust -impl Context { - /// Number of configured shards - pub fn shards(&self) -> u64 - - /// Does cluster have readable replicas? - pub fn has_replicas(&self) -> bool - - /// Does cluster have writable primary? - pub fn has_primary(&self) -> bool - - /// Is this connection in a transaction? - pub fn in_transaction(&self) -> bool - - /// Parsed query AST from pg_query - pub fn statement(&self) -> Statement - - /// Prepared statement parameters (if any) - pub fn parameters(&self) -> Parameters - - /// Force query as READ even if normally a write - pub fn write_override(&self) -> bool -} -``` - -### Plugin Output Options - -```rust -impl Route { - /// No routing decision - pub fn unknown() -> Self - - /// Block query from executing - pub fn block() -> Self - - /// Direct to specific shard - pub fn direct(shard: u64) -> Self - - /// Broadcast to all shards - pub fn all() -> Self - - /// Specify shard and read/write - pub fn new(shard: Shard, read_write: ReadWrite) -> Self -} - -pub enum Shard { - Direct(u64), // Specific shard index - All, // All shards - Unknown, // Use default routing - Blocked, // Block query -} - -pub enum ReadWrite { - Read, // Send to replica - Write, // Send to primary - Unknown, // Use default logic -} -``` - -### Accessing Query Parameters - -For prepared statements, access bind parameters: - -```rust -#[macros::route] -fn route(context: Context) -> Route { - let params = context.parameters(); - - // Get count of parameters - let count = params.count(); - - // Get specific parameter by index (0-based) - if let Some(param) = params.get(0) { - // Decode based on format - if let Some(value) = param.decode(params.parameter_format(0)) { - match value { - ParameterValue::Text(s) => println!("String: {}", s), - ParameterValue::Int(i) => println!("Int: {}", i), - ParameterValue::Query(q) => println!("Query: {}", q), - // ... other types - } - } - } - - Route::unknown() -} -``` - -### Query Blocking Example - -Block queries that don't follow security requirements: - -```rust -#[macros::route] -fn route(context: Context) -> Route { - let ast = context.statement().protobuf(); - - // Block INSERT without explicit column list - if let Some(raw_stmt) = ast.stmts.first() { - if let Some(stmt) = &raw_stmt.stmt { - if let Some(NodeEnum::InsertStmt(insert)) = &stmt.node { - if insert.cols.is_empty() { - return Route::block(); // Block it! - } - } - } - } - - Route::unknown() -} -``` - -### Logging from Plugins - -Use `eprintln!()` or standard Rust logging (routes to stderr): - -```rust -#[macros::route] -fn route(context: Context) -> Route { - eprintln!("Processing query with {} params", context.parameters().count()); - - Route::unknown() -} -``` - -## Performance Considerations - -1. **Plugin initialization is synchronous**: Keep `init()` fast or it blocks PgDog startup -2. **Plugin routes run on async executor**: Can do async work, but not thread-blocking operations -3. **First-match semantics**: Plugins are checked in order; earlier plugins take precedence -4. **No error handling**: Plugin functions cannot return errors or panic - - Must return default `Route::unknown()` in error cases -5. **AST parsing is done before plugins**: Plugins can't change query parsing, only routing - -## Debugging Plugins - -### Check compiler version match: - -```bash -# PgDog shows at startup: -# INFO: 🐕 PgDog v0.1.29 (rustc 1.89.0 (29483883e 2025-08-04)) - -# Update your plugin's Rust: -rustup update -rustc --version -# rustc 1.89.0 (29483883e 2025-08-04) - -# Rebuild plugin: -cargo clean && cargo build --release -``` - -### Check plugin loads: - -```bash -# In PgDog logs, should see: -INFO: loaded "my_plugin" plugin (v0.1.0) [X.XXXXms] - -# If plugin is skipped: -WARN: skipping plugin "my_plugin" because it was compiled with different compiler version -WARN: skipping plugin "my_plugin" because it doesn't expose its Rust compiler version -``` - -### Library not found: - -```bash -# Verify library exists: -ldd /usr/lib/libmy_plugin.so # Linux -otool -L /usr/local/lib/libmy_plugin.dylib # macOS - -# Check LD_LIBRARY_PATH: -echo $LD_LIBRARY_PATH - -# Verify symbols: -nm -D /usr/lib/libmy_plugin.so | grep pgdog_ -``` - -## Example: Tenant-Based Routing Plugin - -```rust -use pgdog_plugin::prelude::*; -use pgdog_macros as macros; - -macros::plugin!(); - -#[macros::init] -fn init() { - eprintln!("Tenant routing plugin initialized"); -} - -#[macros::route] -fn route(context: Context) -> Route { - let params = context.parameters(); - - // Extract tenant_id from function parameter - if let Some(param) = params.get(0) { - if let Some(ParameterValue::Int(tenant_id)) = param.decode(ParameterFormat::Text) { - // Route to shard based on tenant_id - // Assuming simple hash modulo sharding - let shard = (tenant_id as u64) % context.shards(); - return Route::direct(shard); - } - } - - // No tenant_id found, use default routing - Route::unknown() -} - -#[macros::fini] -fn shutdown() { - eprintln!("Tenant routing plugin shutting down"); -} -``` +See [plugins/pgdog-example-plugin/](../plugins/pgdog-example-plugin/) for a working plugin example. ## Testing @@ -693,36 +191,38 @@ fn shutdown() { **Location**: [integration/plugins/](../integration/plugins/) -**Main test suite**: [integration/plugins/extended_spec.rb](../integration/plugins/extended_spec.rb) +The `integration/plugins` directory contains a small test harness and several test-plugin crates used to exercise plugin loading, FFI parameter passing, and API compatibility checks. Key files and behavior: -The integration test validates the FFI parameter passing mechanism by: -- Executing parameterized queries (`SELECT ... WHERE col = $1`) through PgDog with the plugin loaded -- Testing 10 separate connections, each executing 25 prepared statements with different parameter values -- Verifying that parameter values correctly pass through the FFI boundary to the plugin -- Ensuring query results match the input parameters +- **Test harness**: [integration/plugins/extended_spec.rb](../integration/plugins/extended_spec.rb) — an RSpec script that opens multiple connections to PgDog and issues prepared statements (parameterized queries). It verifies query results and that a plugin marker file was created to confirm the plugin's `route()` was invoked. +- **Runner**: [integration/plugins/run.sh](../integration/plugins/run.sh) — builds the test plugins, sets `LD_LIBRARY_PATH` to include the test build outputs, starts PgDog using the integration helpers, runs the RSpec test, and stops PgDog. +- **Test plugins**: [integration/plugins/test-plugins/](../integration/plugins/test-plugins) + - `test-plugin-compatible` — built with the same `pgdog-plugin` ABI; its `route()` calls `assert_context_compatible!()` and writes `route-called.test` the first time it is invoked. The RSpec test checks for this file to confirm the plugin was executed and parameters were seen. + - `test-plugin-main` — validates the main-branch plugin API compatibility by calling `assert_context_compatible!()` on the received `Context` (detects breaking API changes between host and plugin crates). + - `test-plugin-outdated` — intended to simulate an outdated `pgdog-plugin` dependency so the loader should skip it; currently this crate contains a TODO and does not actively panic if loaded, so it serves as a placeholder for the "outdated plugin" scenario. -**What it validates**: -- Prepared statement parameters pass correctly through FFI -- Plugin receives and can decode parameter values -- Extended protocol (parameterized queries) works with plugins -- Multiple connections and queries function correctly -- Query execution continues despite plugin intervention -- The example plugin's parameter logging functionality +What the integration test exercises: +- Parameterized queries travel through the frontend parser, router context creation, and across the FFI boundary into plugins. +- Plugins can access parameters and cluster/context metadata via the `Context` provided by the host. +- The host invokes plugin `route()` during routing; the `test-plugin-compatible` marker file verifies invocation. -**Configuration**: [integration/plugins/pgdog.toml](../integration/plugins/pgdog.toml) configures PgDog to load the example plugin +How the integration runner operates: +- Builds `test-plugin-compatible`, `test-plugin-outdated`, `test-plugin-main`, and the example plugin. +- Sets `LD_LIBRARY_PATH` to include the crates' `target/debug` outputs and workspace targets so PgDog can `dlopen()` the test shared libraries. +- Starts PgDog using integration helpers, runs the RSpec test, then shuts PgDog down. -**How to run**: +How to run locally: ```bash cd integration/plugins bash run.sh ``` -**Note**: This test is NOT part of the default integration test suite ([integration/run.sh](../integration/run.sh)). +Note: The integration runner is separate from the default integration test suite; it requires the integration environment prepared by the helper scripts (see [integration/dev-server.sh](../integration/dev-server.sh) and [integration/common.sh](../integration/common.sh)). ### Unit Tests #### Plugin Data Structures +**Location**: [pgdog-plugin/src/](../../pgdog-plugin/src/) **Location**: [pgdog-plugin/src/](../pgdog-plugin/src/) **Test modules**: @@ -769,22 +269,10 @@ The following areas lack test coverage: - ❌ Multiple plugins with precedence rules - ❌ Plugin routing overrides (shard + read/write combinations) - ❌ Read/write routing decisions +## Testing -#### Error Handling -- ❌ Plugin panics (should not crash PgDog) -- ❌ Invalid route values -- ❌ Malformed plugin libraries -- ❌ ABI violations - -#### Concurrency -- ❌ Concurrent route() calls from multiple threads -- ❌ Thread safety of plugin state -- ❌ Race conditions in plugin initialization - - -## Related Documentation +- **Integration tests:** See [integration/plugins/](../integration/plugins/) and [integration/plugins/extended_spec.rb](../integration/plugins/extended_spec.rb) for FFI and routing tests. Run with `cd integration/plugins && bash run.sh`. +- **Unit tests:** See [pgdog-plugin/src/](../pgdog-plugin/src/) for FFI structure tests. Run with `cd pgdog-plugin && cargo test`. +- **Example plugin tests:** See [plugins/pgdog-example-plugin/src/plugin.rs](../plugins/pgdog-example-plugin/src/plugin.rs). +- **Coverage gaps:** Some error, concurrency, and edge cases are not yet covered. See code comments for details. -- [PgDog Plugin Documentation](https://docs.pgdog.dev/features/plugins/) -- [pgdog-plugin Rust Docs](https://docsrs.pgdog.dev/pgdog_plugin/) -- [pg_query Rust Docs](https://docsrs.pgdog.dev/pg_query/) -- [Query Router Configuration](https://docs.pgdog.dev/configuration/pgdog.toml/plugins/) diff --git a/integration/plugins/.gitignore b/integration/plugins/.gitignore new file mode 100644 index 000000000..03314f77b --- /dev/null +++ b/integration/plugins/.gitignore @@ -0,0 +1 @@ +Cargo.lock diff --git a/integration/plugins/extended_spec.rb b/integration/plugins/extended_spec.rb index 6f3d1ba23..56e972a69 100644 --- a/integration/plugins/extended_spec.rb +++ b/integration/plugins/extended_spec.rb @@ -2,8 +2,18 @@ require 'pg' require 'rspec' +require 'fileutils' describe 'extended protocol' do + let(:plugin_marker_file) { File.expand_path('../test-plugins/test-plugin-compatible/route-called.test', __FILE__) } + + before do + # Delete the marker file before running tests + puts "DEBUG: Looking for plugin marker file at: #{plugin_marker_file}" + FileUtils.rm_f(plugin_marker_file) + expect(File.exist?(plugin_marker_file)).to be false + end + it 'can pass params to plugin' do 10.times do conn = PG.connect('postgres://pgdog:pgdog@127.0.0.1:6432/pgdog') @@ -12,5 +22,8 @@ expect(result[0]['id'].to_i).to eq(i) end end + + # Verify the plugin was actually called + expect(File.exist?(plugin_marker_file)).to be true end end diff --git a/integration/plugins/pgdog.toml b/integration/plugins/pgdog.toml index 1359e9547..686a621c5 100644 --- a/integration/plugins/pgdog.toml +++ b/integration/plugins/pgdog.toml @@ -8,6 +8,15 @@ split_inserts = "error" [[plugins]] name = "pgdog_example_plugin" +[[plugins]] +name = "test_plugin_compatible" + +[[plugins]] +name = "test_plugin_outdated" + +[[plugins]] +name = "test_plugin_main" + [[databases]] name = "pgdog" host = "127.0.0.1" diff --git a/integration/plugins/run.sh b/integration/plugins/run.sh index 48a12f62b..0cf48cfb0 100644 --- a/integration/plugins/run.sh +++ b/integration/plugins/run.sh @@ -3,11 +3,27 @@ set -e SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) source ${SCRIPT_DIR}/../common.sh +export CARGO_TARGET_DIR=${SCRIPT_DIR}/target + +pushd ${SCRIPT_DIR}/test-plugins/test-plugin-compatible +cargo build --release +popd + +pushd ${SCRIPT_DIR}/test-plugins/test-plugin-outdated +cargo build --release +popd + +pushd ${SCRIPT_DIR}/test-plugins/test-plugin-main +cargo build --release +popd + +unset CARGO_TARGET_DIR + pushd ${SCRIPT_DIR}/../../plugins/pgdog-example-plugin cargo build --release popd -export LD_LIBRARY_PATH=${SCRIPT_DIR}/../../target/release +export LD_LIBRARY_PATH=${SCRIPT_DIR}/target/release:${SCRIPT_DIR}/../../target/release export DYLD_LIBRARY_PATH=${LD_LIBRARY_PATH} run_pgdog $SCRIPT_DIR diff --git a/integration/plugins/test-plugins/assertions/Cargo.toml b/integration/plugins/test-plugins/assertions/Cargo.toml new file mode 100644 index 000000000..3a5ad887e --- /dev/null +++ b/integration/plugins/test-plugins/assertions/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "assertions" +version = "0.1.0" +edition = "2024" + +[workspace] diff --git a/integration/plugins/test-plugins/assertions/src/lib.rs b/integration/plugins/test-plugins/assertions/src/lib.rs new file mode 100644 index 000000000..3dbd7bca7 --- /dev/null +++ b/integration/plugins/test-plugins/assertions/src/lib.rs @@ -0,0 +1,23 @@ +/// Asserts that the plugin version of pgdog-plugin is compatible with PgDog's version. +#[macro_export] +// use macro to be able to use the version of pgdog-plugin defined the plugin crate itself +// not this crate's version +macro_rules! assert_context_compatible { + ($context:expr) => { + assert!(!$context.read_only()); + assert!($context.has_primary()); + assert!($context.has_replicas()); + assert_eq!($context.shards(), 1); + assert!(!$context.sharded()); + assert!(!$context.write_override()); + + // Parameters should be accessible and not panic + let params = $context.parameters(); + + assert!(params.len() >= 1); + + // query should be accessible + let query = $context.statement().protobuf(); + assert!(query.nodes().len() >= 1); + }; +} diff --git a/integration/plugins/test-plugins/test-plugin-compatible/Cargo.toml b/integration/plugins/test-plugins/test-plugin-compatible/Cargo.toml new file mode 100644 index 000000000..6f009b708 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-compatible/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test-plugin-compatible" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { path = "../../../../pgdog-plugin" } +assertions = { path = "../assertions" } diff --git a/integration/plugins/test-plugins/test-plugin-compatible/src/lib.rs b/integration/plugins/test-plugins/test-plugin-compatible/src/lib.rs new file mode 100644 index 000000000..3356f1b30 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-compatible/src/lib.rs @@ -0,0 +1,24 @@ +//! Tests that the plugin works as expected. +//! The plugin uses the same version of pgdog-plugin as PgDog and the same rustc version, +//! so it should be loaded and executed. + +use pgdog_plugin::{macros, Context, Route}; +use std::sync::OnceLock; + +macros::plugin!(); + +static ROUTE_CALLED: OnceLock<()> = OnceLock::new(); + +#[macros::route] +fn route(context: Context) -> Route { + assertions::assert_context_compatible!(context); + + // Write to output file on first call only + ROUTE_CALLED.get_or_init(|| { + let file_path = std::path::Path::new(&env!("CARGO_MANIFEST_DIR")) + .join("route-called.test"); + std::fs::write(&file_path, "route method was called").unwrap(); + }); + + Route::unknown() +} diff --git a/integration/plugins/test-plugins/test-plugin-main/Cargo.toml b/integration/plugins/test-plugins/test-plugin-main/Cargo.toml new file mode 100644 index 000000000..6be63f20a --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-main/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test-plugin-main" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { git = "https://github.com/pgdogdev/pgdog", branch = "main" } +assertions = { path = "../assertions" } diff --git a/integration/plugins/test-plugins/test-plugin-main/src/lib.rs b/integration/plugins/test-plugins/test-plugin-main/src/lib.rs new file mode 100644 index 000000000..49793c911 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-main/src/lib.rs @@ -0,0 +1,14 @@ +//! Tests that the plugin is compatible with current main branch of PgDog based on pgdog-plugin version. +//! The plugin from main branch either should be loaded and in this case it should assert the compatibility +//! of the context (that would mean there is no breaking changes in the API) or it should be skipped completely +//! when running pgdog. +use pgdog_plugin::{Context, Route, macros}; + +macros::plugin!(); + +#[macros::route] +fn route(context: Context) -> Route { + assertions::assert_context_compatible!(&context); + + Route::unknown() +} diff --git a/integration/plugins/test-plugins/test-plugin-outdated/Cargo.toml b/integration/plugins/test-plugins/test-plugin-outdated/Cargo.toml new file mode 100644 index 000000000..b1499a08c --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-outdated/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-plugin-outdated" +version = "0.1.0" +edition = "2024" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pgdog-plugin = { version = "0.1.8" } diff --git a/integration/plugins/test-plugins/test-plugin-outdated/src/lib.rs b/integration/plugins/test-plugins/test-plugin-outdated/src/lib.rs new file mode 100644 index 000000000..0d4191a90 --- /dev/null +++ b/integration/plugins/test-plugins/test-plugin-outdated/src/lib.rs @@ -0,0 +1,13 @@ +//! Tests that the plugin that uses outdated version of pgdog-plugin is skipped. +//! If the plugin is loaded, the test will fail because of the panic. + +use pgdog_plugin::{Context, Route, macros}; + +macros::plugin!(); + +#[macros::route] +fn route(_context: Context) -> Route { + // TODO: enable panic after forcing the pgdog-plugin version compatibility check + // panic!("The outdated plugin should not be loaded"); + Route::unknown() +} diff --git a/pgdog-macros/src/lib.rs b/pgdog-macros/src/lib.rs index 65df8ce16..33f97b033 100644 --- a/pgdog-macros/src/lib.rs +++ b/pgdog-macros/src/lib.rs @@ -42,6 +42,14 @@ pub fn plugin(_input: TokenStream) -> TokenStream { *output = version; } } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn pgdog_plugin_api_version(output: *mut pgdog_plugin::PdStr) { + let version = pgdog_plugin::comp::pgdog_plugin_api_version(); + unsafe { + *output = version; + } + } }; TokenStream::from(expanded) } diff --git a/pgdog-plugin/Cargo.toml b/pgdog-plugin/Cargo.toml index 65193addb..3d5b1d7ee 100644 --- a/pgdog-plugin/Cargo.toml +++ b/pgdog-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pgdog-plugin" -version = "0.1.8" +version = "0.2.0" edition = "2021" license = "MIT" authors = ["Lev Kokotov "] diff --git a/pgdog-plugin/src/comp.rs b/pgdog-plugin/src/comp.rs index e49fe0380..83bd01156 100644 --- a/pgdog-plugin/src/comp.rs +++ b/pgdog-plugin/src/comp.rs @@ -6,3 +6,8 @@ use crate::PdStr; pub fn rustc_version() -> PdStr { env!("RUSTC_VERSION").into() } + +/// pgdog-plugin version currently used +pub fn pgdog_plugin_api_version() -> PdStr { + env!("CARGO_PKG_VERSION").into() +} diff --git a/pgdog-plugin/src/context.rs b/pgdog-plugin/src/context.rs index b05ef001a..ee8d259bc 100644 --- a/pgdog-plugin/src/context.rs +++ b/pgdog-plugin/src/context.rs @@ -80,6 +80,7 @@ impl Deref for Statement { /// } /// ``` /// +#[derive(Debug)] pub struct Context { ffi: PdRouterContext, } diff --git a/pgdog-plugin/src/plugin.rs b/pgdog-plugin/src/plugin.rs index fbd03d38b..ad91cfc4f 100644 --- a/pgdog-plugin/src/plugin.rs +++ b/pgdog-plugin/src/plugin.rs @@ -30,6 +30,8 @@ pub struct Plugin<'a> { route: Option>, /// Compiler version. rustc_version: Option>, + /// Plugin API version. + pgdog_plugin_api_version: Option>, /// Plugin version. plugin_version: Option>, } @@ -72,6 +74,7 @@ impl<'a> Plugin<'a> { let fini = unsafe { library.get(b"pgdog_fini\0") }.ok(); let route = unsafe { library.get(b"pgdog_route\0") }.ok(); let rustc_version = unsafe { library.get(b"pgdog_rustc_version\0") }.ok(); + let pgdog_plugin_api_version = unsafe { library.get(b"pgdog_plugin_api_version\0") }.ok(); let plugin_version = unsafe { library.get(b"pgdog_plugin_version\0") }.ok(); Self { @@ -80,6 +83,7 @@ impl<'a> Plugin<'a> { fini, route, rustc_version, + pgdog_plugin_api_version, plugin_version, } } @@ -140,6 +144,17 @@ impl<'a> Plugin<'a> { }) } + /// Get plugin API version based on `pgdog-plugin` crate version. + /// This version must match the version used when building pgdog main executable, + /// otherwise the plugin won't be loaded. + pub fn pgdog_plugin_api_version(&self) -> Option { + let mut output = PdStr::default(); + self.pgdog_plugin_api_version.as_ref().map(|func| unsafe { + func(&mut output as *mut PdStr); + output + }) + } + /// Get plugin version. It's set in plugin's /// `Cargo.toml`. pub fn version(&self) -> Option { diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index dd581891a..8712d96c1 100644 --- a/pgdog/Cargo.toml +++ b/pgdog/Cargo.toml @@ -35,7 +35,7 @@ rustls-native-certs = "0.8" rustls-pki-types = "1" arc-swap = "1" toml = "0.8" -pgdog-plugin = { path = "../pgdog-plugin", version = "0.1.8" } +pgdog-plugin.workspace = true tokio-util = { version = "0.7", features = ["rt"] } fnv = "1" scram = { git = "https://github.com/pgdogdev/scram.git", rev = "848003d" } diff --git a/pgdog/src/plugin/mod.rs b/pgdog/src/plugin/mod.rs index 23cbbf6da..cd3f110e1 100644 --- a/pgdog/src/plugin/mod.rs +++ b/pgdog/src/plugin/mod.rs @@ -36,6 +36,7 @@ pub fn load(names: &[&str]) -> Result<(), libloading::Error> { let _ = LIBS.set(libs); let rustc_version = comp::rustc_version(); + let pgdog_plugin_api_version = comp::pgdog_plugin_api_version(); let mut plugins = vec![]; for (i, name) in names.iter().enumerate() { @@ -60,6 +61,30 @@ pub fn load(names: &[&str]) -> Result<(), libloading::Error> { continue; } + // Check plugin api version + if let Some(plugin_api_version) = plugin.pgdog_plugin_api_version() { + if plugin_api_version != pgdog_plugin_api_version { + warn!( + "skipping plugin \"{}\" because it was compiled with different plugin API version ({})", + plugin.name(), + plugin_api_version.deref() + ); + continue; + } + } else { + warn!( + "plugin {} doesn't expose its plugin API version, please update version of pgdog-plugin crate in your plugin", plugin.name() + ); + + // TODO: use this after after some time when we want to force version + // compatibility based on pgdog-plugin version + // warn!( + // "skipping plugin \"{}\" because it doesn't expose its plugin API version", + // plugin.name() + // ); + // continue; + } + if plugin.init() { debug!("plugin \"{}\" initialized", name); } diff --git a/pgdog/src/util.rs b/pgdog/src/util.rs index 8c735dbd8..23f3972d3 100644 --- a/pgdog/src/util.rs +++ b/pgdog/src/util.rs @@ -115,9 +115,10 @@ pub fn escape_identifier(s: &str) -> String { /// Get PgDog's version string. pub fn pgdog_version() -> String { format!( - "v{} [main@{}, {}]", + "v{} [main@{}, pgdog-plugin {}, {}]", env!("CARGO_PKG_VERSION"), env!("GIT_HASH"), + comp::pgdog_plugin_api_version().deref(), comp::rustc_version().deref() ) } diff --git a/plugins/pgdog-example-plugin/Cargo.toml b/plugins/pgdog-example-plugin/Cargo.toml index fbe32236f..7b8b0bf2d 100644 --- a/plugins/pgdog-example-plugin/Cargo.toml +++ b/plugins/pgdog-example-plugin/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" crate-type = ["rlib", "cdylib"] [dependencies] -pgdog-plugin = { version = "0.1.8", path = "../../pgdog-plugin" } +pgdog-plugin.workspace = true once_cell = "1" parking_lot = "0.12" thiserror = "2" From 51225b6b59f5938e99f892aae3a1eebd45ae7354 Mon Sep 17 00:00:00 2001 From: meskill <8974488+meskill@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:03:43 +0000 Subject: [PATCH 3/4] fix(pgdog): use semver for the pgdog-plugin validation --- Cargo.lock | 1 + pgdog/Cargo.toml | 1 + pgdog/src/plugin/mod.rs | 16 ++++++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 322caaead..d1c6ca184 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,6 +2438,7 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "scram", + "semver", "serde", "serde_json", "sha1", diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index 8712d96c1..9464bb7ee 100644 --- a/pgdog/Cargo.toml +++ b/pgdog/Cargo.toml @@ -45,6 +45,7 @@ futures = "0.3" csv-core = "0.1" pg_query = { git = "https://github.com/pgdogdev/pg_query.rs.git", rev = "4f79b92fe4d630b1f253f27f13c9096c77530fd6" } regex = "1" +semver = "1" uuid = { version = "1", features = ["v4", "serde"] } url = "2" ratatui = { version = "0.30.0-alpha.1", optional = true } diff --git a/pgdog/src/plugin/mod.rs b/pgdog/src/plugin/mod.rs index cd3f110e1..426c985b4 100644 --- a/pgdog/src/plugin/mod.rs +++ b/pgdog/src/plugin/mod.rs @@ -6,12 +6,24 @@ use once_cell::sync::OnceCell; use pgdog_plugin::libloading::Library; use pgdog_plugin::Plugin; use pgdog_plugin::{comp, libloading}; +use semver::Version; use tokio::time::Instant; use tracing::{debug, error, info, warn}; static LIBS: OnceCell> = OnceCell::new(); pub static PLUGINS: OnceCell> = OnceCell::new(); +// Compare semantic versions by major and minor only (ignore patch/bugfix). +fn same_major_minor(a: &str, b: &str) -> bool { + match (Version::parse(a), Version::parse(b)) { + (Ok(va), Ok(vb)) => va.major == vb.major && va.minor == vb.minor, + _ => { + warn!("failed to parse plugin API version(s) ('{a}' or '{b}'), skipping plugin",); + false + } + } +} + /// Load plugins. /// /// # Safety @@ -61,9 +73,9 @@ pub fn load(names: &[&str]) -> Result<(), libloading::Error> { continue; } - // Check plugin api version + // Check plugin api version (compare major.minor only) if let Some(plugin_api_version) = plugin.pgdog_plugin_api_version() { - if plugin_api_version != pgdog_plugin_api_version { + if !same_major_minor(plugin_api_version.deref(), pgdog_plugin_api_version.deref()) { warn!( "skipping plugin \"{}\" because it was compiled with different plugin API version ({})", plugin.name(), From a4717023baba16c5e14ee6f3ea913c139d077dfa Mon Sep 17 00:00:00 2001 From: meskill <8974488+meskill@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:44:06 +0000 Subject: [PATCH 4/4] chore: cleanup plugins dependencies --- Cargo.lock | 70 +++---------------------- Cargo.toml | 10 +++- pgdog-macros/src/lib.rs | 10 ---- pgdog-plugin-build/Cargo.toml | 10 ---- pgdog-plugin-build/src/lib.rs | 40 -------------- pgdog-plugin/Cargo.toml | 3 -- pgdog-plugin/src/order_by.rs | 1 - plugins/pgdog-example-plugin/Cargo.toml | 2 +- 8 files changed, 15 insertions(+), 131 deletions(-) delete mode 100644 pgdog-plugin-build/Cargo.toml delete mode 100644 pgdog-plugin-build/src/lib.rs delete mode 100644 pgdog-plugin/src/order_by.rs diff --git a/Cargo.lock b/Cargo.lock index d1c6ca184..e307307b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2450,7 +2450,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "toml 0.8.22", + "toml", "tracing", "tracing-subscriber", "url", @@ -2468,7 +2468,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.12", - "toml 0.8.22", + "toml", "tracing", "url", "uuid", @@ -2498,19 +2498,9 @@ name = "pgdog-plugin" version = "0.2.0" dependencies = [ "bindgen 0.71.1", - "libc", "libloading", "pg_query", "pgdog-macros", - "toml 0.9.5", - "tracing", -] - -[[package]] -name = "pgdog-plugin-build" -version = "0.1.1" -dependencies = [ - "toml 0.9.5", ] [[package]] @@ -3496,15 +3486,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4334,26 +4315,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" dependencies = [ "serde", - "serde_spanned 0.6.8", - "toml_datetime 0.6.9", + "serde_spanned", + "toml_datetime", "toml_edit", ] -[[package]] -name = "toml" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", - "toml_parser", - "toml_writer", - "winnow", -] - [[package]] name = "toml_datetime" version = "0.6.9" @@ -4363,15 +4329,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" -dependencies = [ - "serde", -] - [[package]] name = "toml_edit" version = "0.22.26" @@ -4380,33 +4337,18 @@ checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", "serde", - "serde_spanned 0.6.8", - "toml_datetime 0.6.9", + "serde_spanned", + "toml_datetime", "toml_write", "winnow", ] -[[package]] -name = "toml_parser" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" -dependencies = [ - "winnow", -] - [[package]] name = "toml_write" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" -[[package]] -name = "toml_writer" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" - [[package]] name = "tower" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 6a58c7a96..8873fecaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,14 @@ members = [ "examples/demo", "integration/rust", - "pgdog", "pgdog-config", "pgdog-macros", - "pgdog-plugin", "pgdog-plugin-build", "pgdog-postgres-types", "pgdog-stats", "pgdog-vector", "plugins/pgdog-example-plugin", + "pgdog", + "pgdog-config", + "pgdog-macros", + "pgdog-plugin", + "pgdog-postgres-types", + "pgdog-stats", + "pgdog-vector", + "plugins/pgdog-example-plugin", ] resolver = "2" diff --git a/pgdog-macros/src/lib.rs b/pgdog-macros/src/lib.rs index 33f97b033..11c15928a 100644 --- a/pgdog-macros/src/lib.rs +++ b/pgdog-macros/src/lib.rs @@ -25,16 +25,6 @@ pub fn plugin(_input: TokenStream) -> TokenStream { } } - #[unsafe(no_mangle)] - pub unsafe extern "C" fn pgdog_pg_query_version(output: *mut pgdog_plugin::PdStr) { - let version: pgdog_plugin::PdStr = option_env!("PGDOG_PGQUERY_VERSION") - .unwrap_or_default() - .into(); - unsafe { - *output = version; - } - } - #[unsafe(no_mangle)] pub unsafe extern "C" fn pgdog_plugin_version(output: *mut pgdog_plugin::PdStr) { let version: pgdog_plugin::PdStr = env!("CARGO_PKG_VERSION").into(); diff --git a/pgdog-plugin-build/Cargo.toml b/pgdog-plugin-build/Cargo.toml deleted file mode 100644 index 98f6cc758..000000000 --- a/pgdog-plugin-build/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "pgdog-plugin-build" -version = "0.1.1" -edition = "2024" -authors = ["Lev Kokotov "] -license = "MIT" -description = "Build-time helpers for PgDog plugins" - -[dependencies] -toml = "0.9" diff --git a/pgdog-plugin-build/src/lib.rs b/pgdog-plugin-build/src/lib.rs deleted file mode 100644 index fb2548a23..000000000 --- a/pgdog-plugin-build/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Build-time helpers for PgDog plugins. -//! -//! Include this package as a build dependency only. -//! - -use std::{fs::File, io::Read}; - -/// Extracts the `pg_query` crate version from `Cargo.toml` -/// and sets it as an environment variable. -/// -/// This should be used at build time only. It expects `Cargo.toml` to be present in the same -/// folder as `build.rs`. -/// -/// ### Note -/// -/// You should have a strict version constraint on `pg_query`, for example: -/// -/// ```toml -/// pg_query = "6.1.0" -/// ``` -/// -/// If the version in your plugin doesn't match what PgDog is using, your plugin won't be loaded. -/// -pub fn pg_query_version() { - let mut contents = String::new(); - if let Ok(mut file) = File::open("Cargo.toml") { - file.read_to_string(&mut contents).ok(); - } else { - panic!("Cargo.toml not found"); - } - - let contents: Option = toml::from_str(&contents).ok(); - if let Some(contents) = contents - && let Some(dependencies) = contents.get("dependencies") - && let Some(pg_query) = dependencies.get("pg_query") - && let Some(version) = pg_query.as_str() - { - println!("cargo:rustc-env=PGDOG_PGQUERY_VERSION={}", version); - } -} diff --git a/pgdog-plugin/Cargo.toml b/pgdog-plugin/Cargo.toml index 3d5b1d7ee..477c21ee3 100644 --- a/pgdog-plugin/Cargo.toml +++ b/pgdog-plugin/Cargo.toml @@ -15,11 +15,8 @@ crate-type = ["rlib", "cdylib"] [dependencies] libloading = "0.8" -libc = "0.2" -tracing = "0.1" pg_query = { git = "https://github.com/pgdogdev/pg_query.rs.git", rev = "4f79b92fe4d630b1f253f27f13c9096c77530fd6" } pgdog-macros = { path = "../pgdog-macros", version = "0.1.1" } -toml = "0.9" [build-dependencies] bindgen = "0.71.0" diff --git a/pgdog-plugin/src/order_by.rs b/pgdog-plugin/src/order_by.rs deleted file mode 100644 index 8b1378917..000000000 --- a/pgdog-plugin/src/order_by.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/pgdog-example-plugin/Cargo.toml b/plugins/pgdog-example-plugin/Cargo.toml index 7b8b0bf2d..905c6098d 100644 --- a/plugins/pgdog-example-plugin/Cargo.toml +++ b/plugins/pgdog-example-plugin/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["rlib", "cdylib"] +crate-type = ["cdylib"] [dependencies] pgdog-plugin.workspace = true