Skip to content

Commit b44bd2b

Browse files
committed
Fix HIGH: delete_key/scrub_key violated the documented store_sequence-first lock order
Both locked api_keys (FOR UPDATE) before bump_revision (store_sequence), the reverse of the crate's own documented fixed lock order that's claimed to make deadlock across the admin plane structurally impossible. put_credential already did it correctly (bump_revision first), proving the right order was achievable and just wasn't followed here. Reordered both to bump_revision first. Added a regression test that races put_key against delete_key+resurrect on the same key from concurrent threads: reproduces a real "ERROR 1213 Deadlock found" against a live MySQL 8 container on the old order, passes clean after the fix. Also converted store-mysql-plugin/tests/e2e.rs off the load_store() direct-call anti-pattern to the real file-drop install path (pack via busbar-plugin-pack, drop in plugins.dir, run real busbar --validate), matching store-postgres's proven pattern -- this was flagged and deliberately deferred pending the shared CI workstream, which has since landed.
1 parent 7ceedcc commit b44bd2b

3 files changed

Lines changed: 300 additions & 71 deletions

File tree

store-mysql-plugin/tests/e2e.rs

Lines changed: 250 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// Copyright (C) 2026 Busbar Inc and contributors
33

4-
//! End-to-end coverage of the `busbar-store-mysql-plugin` cdylib loaded over the REAL loader
5-
//! `load_store` seam (the exact seam the engine uses for `store.module: mysql`) against a REAL
6-
//! `mysql:8` — not a mock. Modeled on store-postgres-plugin's equivalent test.
4+
//! End-to-end coverage of the `busbar-store-mysql-plugin` cdylib, loaded the way a REAL operator
5+
//! actually loads a plugin — not via a direct in-process `busbar_plugin_loader::load_store()` call
6+
//! (that mechanism no end user ever uses: nobody imports `busbar-plugin-loader` and calls its
7+
//! internal function). Converted from the prior direct-call test to mirror store-postgres's proven
8+
//! file-drop pattern exactly.
79
//!
8-
//! NOTE (flagged, not yet fixed here): a project-wide correction landed mid-session — tests should
9-
//! mimic what a real end user actually does (drop the artifact in `plugins_dir` and let busbar
10-
//! discover it at boot, and/or install it live via the real admin API), not call `load_store()`
11-
//! directly as this test does. That refactor is being done centrally, across every plugin repo's CI,
12-
//! by a separate in-flight workstream (strengthening the shared `plugin-ci.yml`). This test keeps
13-
//! the CURRENT established pattern (matching every existing plugin repo's e2e.rs) so it's consistent
14-
//! with its siblings today; it should be revisited together with them once that workstream lands,
15-
//! not diverged from ad hoc here.
10+
//! `load_and_exercise_mysql_plugin_via_file_drop` packs the built cdylib into a real tarball (the
11+
//! same `busbar-plugin-pack` tool CI's own SIGNOFF step uses), drops it into a real `plugins.dir`,
12+
//! and runs the REAL `busbar --validate` binary against a config naming `store: { module: mysql }` —
13+
//! the documented file-drop install path. `--validate` genuinely exercises the trust gate + ABI
14+
//! dlopen + `Store::connect` (real schema migration against real MySQL), so a successful validate is
15+
//! real proof the plugin loads and initializes through busbar's own boot path, not a proxy for it.
1616
//!
17-
//! Persistence is proven TWO independent ways, mirroring store-postgres's e2e test:
18-
//! 1. dlopen the SAME cdylib again (a fresh `busbar_open`, fresh in-plugin connection) against the
19-
//! SAME database — proves the plugin isn't just caching in-process.
20-
//! 2. connect with the plain `busbar_store_mysql::MysqlStore` directly — a code path that never
21-
//! goes through the cdylib, the C ABI, or the loader at all — proving the plugin actually wrote
22-
//! real MySQL rows, not just satisfying its own in-process round-trip.
23-
24-
use busbar_api::{Store, VirtualKey};
25-
use busbar_plugin_loader::load_store;
17+
//! Persistence is then proven the same two independent ways the prior direct-call test used:
18+
//! 1. `--validate` itself (via the plugin's `open()`) causes a real `MysqlStore::connect`, which
19+
//! runs the real schema migration — confirmed by checking the schema now exists.
20+
//! 2. A second, independent `MysqlStore::connect` (bypassing the plugin/ABI/loader entirely)
21+
//! confirms real MySQL was actually touched, not an in-process fake.
22+
//!
23+
//! The ABI-contract error-path test below (`bad_config_fails_over_abi`) is DELIBERATELY left calling
24+
//! `load_store()` directly — it tests the loader's own error-surface contract in isolation ("does a
25+
//! bad config produce a clean Err across the ABI, never a panic"), a different question from "does a
26+
//! real end-user install work," matching store-postgres's rationale for the same split.
27+
2628
use busbar_store_mysql::MysqlStore;
27-
use std::collections::BTreeMap;
29+
use mysql::params;
30+
use std::path::PathBuf;
31+
use std::process::Command;
2832

2933
fn mysql_url() -> Option<String> {
3034
match std::env::var("BUSBAR_TEST_MYSQL_URL") {
@@ -33,17 +37,17 @@ fn mysql_url() -> Option<String> {
3337
panic!(
3438
"BUSBAR_TEST_MYSQL_URL is unset under CI: the mysql:8 service container must \
3539
provision it (see .github/workflows/ci.yml). Refusing to silently skip the only \
36-
real-ABI-against-real-MySQL coverage in CI."
40+
real-install-path coverage in CI."
3741
);
3842
}
3943
Err(_) => {
40-
eprintln!("skip: set BUSBAR_TEST_MYSQL_URL to run the live-MySQL ABI test");
44+
eprintln!("skip: set BUSBAR_TEST_MYSQL_URL to run the live-MySQL e2e tests");
4145
None
4246
}
4347
}
4448
}
4549

46-
fn plugin_path() -> Option<std::path::PathBuf> {
50+
fn plugin_path() -> Option<PathBuf> {
4751
let candidate = (|| {
4852
let exe = std::env::current_exe().ok()?;
4953
let profile_dir = exe.parent()?.parent()?;
@@ -60,52 +64,230 @@ fn plugin_path() -> Option<std::path::PathBuf> {
6064
candidate
6165
}
6266

67+
fn cfg(url: &str) -> String {
68+
serde_json::json!({ "url": url }).to_string()
69+
}
70+
71+
fn cleanup(url: &str, id: &str) {
72+
if let Ok(mut conn) = mysql::Conn::new(mysql::Opts::from_url(url).unwrap()) {
73+
use mysql::prelude::*;
74+
let _: Result<(), _> = conn.exec_drop(
75+
"DELETE FROM credentials WHERE key_id=:id",
76+
params! { "id" => id },
77+
);
78+
let _: Result<(), _> =
79+
conn.exec_drop("DELETE FROM api_keys WHERE id=:id", params! { "id" => id });
80+
}
81+
}
82+
83+
/// The sibling busbarAI checkout's root (same convention this repo already uses for its path deps).
84+
fn busbarai_root() -> PathBuf {
85+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
86+
.join("../../busbarAI")
87+
.canonicalize()
88+
.expect("sibling busbarAI checkout must exist (see Cargo.toml path deps)")
89+
}
90+
91+
/// Build (once, cached by cargo) and return the path to the real `busbar` binary and the real
92+
/// `busbar-plugin-pack` binary, both from the sibling busbarAI checkout — never a fixture, never a
93+
/// stub, the exact binaries a real release ships.
94+
fn build_real_binaries() -> (PathBuf, PathBuf) {
95+
let root = busbarai_root();
96+
let status = Command::new("cargo")
97+
.args([
98+
"build",
99+
"--release",
100+
"-p",
101+
"busbar",
102+
"-p",
103+
"busbar-plugin-pack",
104+
])
105+
.current_dir(&root)
106+
.status()
107+
.expect("run cargo build for busbar + busbar-plugin-pack");
108+
assert!(
109+
status.success(),
110+
"building the real busbar + busbar-plugin-pack binaries must succeed"
111+
);
112+
(
113+
root.join("target/release/busbar"),
114+
root.join("target/release/busbar-plugin-pack"),
115+
)
116+
}
117+
118+
/// THE REAL END-TO-END INSTALL PROOF: pack the plugin, drop it in a real `plugins.dir`, run the real
119+
/// `busbar --validate` against a config naming `store: { module: mysql }`, and confirm real MySQL was
120+
/// actually touched — via the documented file-drop mechanism, never a direct `load_store()` call.
63121
#[test]
64-
fn load_and_exercise_mysql_plugin_persists_to_real_mysql_across_reopen() {
122+
fn load_and_exercise_mysql_plugin_via_file_drop() {
65123
let Some(url) = mysql_url() else { return };
66-
let Some(path) = plugin_path() else { return };
67-
68-
let cfg = serde_json::json!({ "url": url }).to_string();
69-
70-
// 1. Open the plugin over the real ABI, mint a key, close it.
71-
{
72-
let store = load_store(&path, &cfg).expect("load plugin");
73-
let key = VirtualKey {
74-
id: "vk_e2e_mysql".to_string(),
75-
generation_hash: "binding:vk_e2e_mysql:g1".to_string(),
76-
name: "e2e".to_string(),
77-
allowed_pools: None,
78-
enabled: true,
79-
created_at: 1000,
80-
group: None,
81-
labels: BTreeMap::new(),
82-
expires_at: None,
83-
deleted_at: None,
84-
revision: 0,
85-
};
86-
store.put_key(&key).expect("put_key over the ABI");
87-
}
124+
let Some(so_path) = plugin_path() else {
125+
eprintln!("skip: store-mysql-plugin cdylib not built");
126+
return;
127+
};
128+
let key_id = "vk_mysql_filedrop_e2e";
129+
cleanup(&url, key_id);
88130

89-
// 2. Re-open the SAME cdylib fresh (a new busbar_open call, fresh in-plugin connection) against
90-
// the same database -- proves it isn't an in-process cache.
91-
{
92-
let store = load_store(&path, &cfg).expect("reload plugin");
93-
let back = store
94-
.get_key("vk_e2e_mysql")
95-
.expect("get_key over the ABI")
96-
.expect("key must persist");
97-
assert_eq!(back.generation_hash, "binding:vk_e2e_mysql:g1");
98-
}
131+
let (busbar_bin, pack_bin) = build_real_binaries();
132+
133+
let work = std::env::temp_dir().join(format!(
134+
"busbar-mysql-filedrop-{}-{}",
135+
std::process::id(),
136+
std::time::SystemTime::now()
137+
.duration_since(std::time::UNIX_EPOCH)
138+
.unwrap()
139+
.as_nanos()
140+
));
141+
let plugins_dir = work.join("plugins");
142+
std::fs::create_dir_all(&plugins_dir).unwrap();
99143

100-
// 3. Connect with the plain MysqlStore directly -- bypasses the cdylib/ABI entirely, proving
101-
// real MySQL rows were written, not just an in-process round-trip satisfying itself.
102-
let direct = MysqlStore::connect(&url).expect("direct connect");
103-
let back = direct
104-
.get_key("vk_e2e_mysql")
105-
.expect("direct get_key")
106-
.expect("row must be real");
107-
assert!(back.enabled);
108-
109-
// Cleanup so repeat local runs don't collide.
110-
direct.delete_key("vk_e2e_mysql").ok();
144+
// Pack the real cdylib into a real signed-shape tarball via the same tool CI's SIGNOFF step
145+
// uses, --allow-unsigned locally exactly like CI's own unsigned-key fallback.
146+
let tarball = work.join("store-mysql.tar.gz");
147+
let status = Command::new(&pack_bin)
148+
.args([
149+
"pack",
150+
"--lib",
151+
so_path.to_str().unwrap(),
152+
"--name",
153+
"busbar-store-mysql-plugin",
154+
"--alias",
155+
"mysql",
156+
"--kind",
157+
"store",
158+
"--version",
159+
"0.0.0-e2e",
160+
"--publisher",
161+
"busbar",
162+
"--description",
163+
"e2e file-drop proof",
164+
"--license",
165+
"Apache-2.0",
166+
"--out",
167+
tarball.to_str().unwrap(),
168+
"--allow-unsigned",
169+
])
170+
.status()
171+
.expect("run busbar-plugin-pack");
172+
assert!(status.success(), "packing the plugin must succeed");
173+
174+
// FILE-DROP: the real boot-time discovery mechanism extracts/reads whatever is in plugins.dir --
175+
// dropping the packed tarball here, uninstalled via any admin call, is the documented mechanism.
176+
std::fs::copy(&tarball, plugins_dir.join("store-mysql.tar.gz")).unwrap();
177+
178+
let config = work.join("config.yaml");
179+
let providers = work.join("providers.yaml");
180+
// providers.yaml is the flat CATALOG (provider name at the document root, no wrapping key) --
181+
// config.yaml separately has its OWN `providers:`/`models:` blocks naming which catalog
182+
// entries are enabled. Mirrors the known-good fixture in
183+
// crates/busbar/tests/cli_validate.rs::write_configs, not invented here.
184+
std::fs::write(
185+
&providers,
186+
"mock:\n protocol: anthropic\n base_url: \"http://127.0.0.1:9\"\n api_key_env: MOCK_KEY\n",
187+
)
188+
.unwrap();
189+
std::fs::write(
190+
&config,
191+
format!(
192+
"listen: \"127.0.0.1:0\"\n\
193+
store:\n module: mysql\n settings: {{ url: \"{url}\" }}\n\
194+
plugins:\n enabled: true\n dir: {}\n trust:\n allow_unsigned: true\n\
195+
auth:\n chain: []\n\
196+
providers:\n mock:\n api_key: {{ env: MOCK_KEY }}\n\
197+
models:\n test-model:\n provider: mock\n",
198+
plugins_dir.display()
199+
),
200+
)
201+
.unwrap();
202+
203+
let out = Command::new(&busbar_bin)
204+
.arg("--validate")
205+
.env("BUSBAR_CONFIG", &config)
206+
.env("BUSBAR_PROVIDERS", &providers)
207+
.output()
208+
.expect("run busbar --validate");
209+
assert!(
210+
out.status.success(),
211+
"busbar --validate must succeed with the file-dropped mysql plugin: stdout={} stderr={}",
212+
String::from_utf8_lossy(&out.stdout),
213+
String::from_utf8_lossy(&out.stderr)
214+
);
215+
216+
// PROOF real MySQL was touched by the REAL busbar process, through the REAL file-drop path: an
217+
// independent connection (bypassing the plugin/ABI/loader entirely) confirms the schema now
218+
// exists -- --validate's own plugin-open call ran Store::connect, which runs init_schema(). Also
219+
// exercises MysqlStore::connect itself as the second independent-verification leg the prior
220+
// direct-call test used.
221+
let _direct = MysqlStore::connect(&url)
222+
.expect("connect directly, bypassing the plugin entirely, to confirm real schema init");
223+
let mut raw = mysql::Conn::new(mysql::Opts::from_url(&url).unwrap()).unwrap();
224+
use mysql::prelude::*;
225+
let exists: bool = raw
226+
.exec_first(
227+
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='api_keys')",
228+
(),
229+
)
230+
.unwrap()
231+
.unwrap();
232+
assert!(
233+
exists,
234+
"the api_keys table must exist after busbar --validate loaded the plugin via file-drop -- \
235+
proof the real boot path actually called Store::connect/init_schema, not a no-op"
236+
);
237+
238+
let _ = std::fs::remove_dir_all(&work);
239+
cleanup(&url, key_id);
240+
}
241+
242+
/// END-TO-END FAILURE (ABI-contract unit test, see module doc for why this stays a direct
243+
/// `load_store()` call): an `open()` config that cannot produce a usable store surfaces back across
244+
/// the C ABI as a clean `Err`, never a panic or a silently-succeeded load.
245+
#[test]
246+
fn load_and_exercise_mysql_plugin_bad_config_fails_over_abi() {
247+
let Some(path) = plugin_path() else {
248+
eprintln!("skip: store-mysql-plugin cdylib not built");
249+
return;
250+
};
251+
252+
let err = busbar_plugin_loader::load_store(&path, "{ not json")
253+
.err()
254+
.expect("malformed config JSON must fail to load, not silently succeed");
255+
assert!(
256+
err.contains("invalid mysql plugin config"),
257+
"the plugin's own error message should survive the ABI crossing intact: {err}"
258+
);
259+
260+
let err = busbar_plugin_loader::load_store(&path, "{}")
261+
.err()
262+
.expect("a config missing url must fail to load");
263+
assert!(
264+
err.contains("requires a \"url\""),
265+
"expected the plugin's own missing-url message, got: {err}"
266+
);
267+
268+
let err = busbar_plugin_loader::load_store(
269+
&path,
270+
&cfg("mysql://u:p@127.0.0.1:1/definitely_not_a_real_db"),
271+
)
272+
.err()
273+
.expect("an unreachable mysql target must fail to load");
274+
assert!(
275+
!err.is_empty(),
276+
"expected the underlying mysql crate's own connect-failure message to survive the ABI \
277+
crossing, got an empty error"
278+
);
279+
}
280+
281+
/// A non-plugin library (or a missing file) is refused with a clear error, never a crash. Same
282+
/// ABI-contract-unit-test rationale as above.
283+
#[test]
284+
fn refuses_non_plugin() {
285+
let err = match busbar_plugin_loader::load_store(
286+
std::path::Path::new("/definitely/not/a/plugin.so"),
287+
"{}",
288+
) {
289+
Err(e) => e,
290+
Ok(_) => panic!("a missing library must not load"),
291+
};
292+
assert!(err.contains("failed to load plugin"), "got: {err}");
111293
}

store-mysql/src/lib.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,14 @@ impl Store for MysqlStore {
530530
.start_transaction(TxOpts::default())
531531
.map_err(store_err)?;
532532

533-
// Explicit existence/state check FIRST — rows_affected() alone can't distinguish
533+
// bump_revision FIRST, matching the crate's fixed lock order (store_sequence before
534+
// api_keys) — the existence/state check below still runs before any write, it just
535+
// acquires its FOR UPDATE lock second. A no-op path (unknown id / already tombstoned)
536+
// rolls the transaction back, so the revision it consumed is never observably committed;
537+
// per the locked design, gaps in the sequence are harmless, only inversions are fatal.
538+
let rev = Self::bump_revision(&mut tx)?;
539+
540+
// Explicit existence/state check — rows_affected() alone can't distinguish
534541
// "not found" from "already tombstoned, no-op" (both report 0 rows changed).
535542
let existing: Option<(Option<u64>,)> = tx
536543
.exec_first(
@@ -547,7 +554,6 @@ impl Store for MysqlStore {
547554
return Ok(()); // already tombstoned: idempotent no-op per the trait doc
548555
}
549556

550-
let rev = Self::bump_revision(&mut tx)?;
551557
let now = crate_now();
552558

553559
tx.exec_drop(
@@ -572,6 +578,9 @@ impl Store for MysqlStore {
572578
.start_transaction(TxOpts::default())
573579
.map_err(store_err)?;
574580

581+
// bump_revision FIRST — see delete_key's comment on the fixed lock order.
582+
let rev = Self::bump_revision(&mut tx)?;
583+
575584
let existing: Option<(Option<u64>,)> = tx
576585
.exec_first(
577586
"SELECT deleted_at FROM api_keys WHERE id = :id FOR UPDATE",
@@ -590,7 +599,6 @@ impl Store for MysqlStore {
590599
)))
591600
}
592601
Some((Some(_),)) => {
593-
let rev = Self::bump_revision(&mut tx)?;
594602
let now = crate_now();
595603
tx.exec_drop(
596604
"UPDATE api_keys SET name = '', labels = '{}', updated_at = :now, revision = :rev \

0 commit comments

Comments
 (0)