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..e5a9d122f 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,9 @@ pgdog-plugin/src/bindings.rs local/ integration/log.txt .pycache + +# Ignore test internal files +integration/pgdog.config +integration/**/.bundle +integration/**/vendor +integration/**/*.test 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/Cargo.lock b/Cargo.lock index 9b9961483..e307307b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,6 +2438,7 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "scram", + "semver", "serde", "serde_json", "sha1", @@ -2449,7 +2450,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "toml 0.8.22", + "toml", "tracing", "tracing-subscriber", "url", @@ -2467,7 +2468,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.12", - "toml 0.8.22", + "toml", "tracing", "url", "uuid", @@ -2494,22 +2495,12 @@ dependencies = [ [[package]] name = "pgdog-plugin" -version = "0.1.8" +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]] @@ -3495,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" @@ -4333,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" @@ -4362,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" @@ -4379,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 4d87a8543..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" @@ -23,3 +29,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 new file mode 100644 index 000000000..2e695685f --- /dev/null +++ b/docs/PLUGIN_SYSTEM.md @@ -0,0 +1,278 @@ +# PgDog Plugin System - Overview & Essentials + +This document summarizes PgDog's plugin system: architecture, execution flow, integration points, and key development guidelines. For full details, see the referenced source files. + +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 + +PgDog's plugin system consists of four main crates: + +- **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" + +Store plugins in static PLUGINS (OnceCell) + ↓ +PgDog ready to serve connections +``` + +**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 + ↓ +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](../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" + ↓ +Clean up remaining resources + ↓ +Process exits +``` + +**Location**: `pgdog/src/plugin/mod.rs` lines 77-83 + +## Plugin Development + +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. + +See the [plugins/pgdog-example-plugin/](../plugins/pgdog-example-plugin/) for a full example. + +## Integration Points + +- **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 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). + +## FFI & ABI Notes + +- 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. + +### Maintainability Issues & Pain Points + +- **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. + +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. + +## Plugin Context & Output + +- 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). + +## Performance & Best Practices + +- 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. + +## Debugging + +- Check PgDog logs for plugin load status and version mismatches. +- Use `ldd`/`otool` to verify library paths and symbols if plugins fail to load. + +## Example + +See [plugins/pgdog-example-plugin/](../plugins/pgdog-example-plugin/) for a working plugin example. + +## Testing + +### Integration Tests + +**Location**: [integration/plugins/](../integration/plugins/) + +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: + +- **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 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. + +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 locally: +```bash +cd integration/plugins +bash 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**: +- [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 +## Testing + +- **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. + 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..11c15928a 100644 --- a/pgdog-macros/src/lib.rs +++ b/pgdog-macros/src/lib.rs @@ -26,18 +26,16 @@ 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(); + pub unsafe extern "C" fn pgdog_plugin_version(output: *mut pgdog_plugin::PdStr) { + let version: pgdog_plugin::PdStr = env!("CARGO_PKG_VERSION").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(); + 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; } 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 65193addb..477c21ee3 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 "] @@ -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/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/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/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..9464bb7ee 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" } @@ -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 23cbbf6da..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 @@ -36,6 +48,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 +73,30 @@ pub fn load(names: &[&str]) -> Result<(), libloading::Error> { continue; } + // Check plugin api version (compare major.minor only) + if let Some(plugin_api_version) = plugin.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(), + 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..905c6098d 100644 --- a/plugins/pgdog-example-plugin/Cargo.toml +++ b/plugins/pgdog-example-plugin/Cargo.toml @@ -4,10 +4,10 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["rlib", "cdylib"] +crate-type = ["cdylib"] [dependencies] -pgdog-plugin = { version = "0.1.8", path = "../../pgdog-plugin" } +pgdog-plugin.workspace = true once_cell = "1" parking_lot = "0.12" thiserror = "2"