Files
rustfs/crates/targets/tests/mysql_integration.rs
T
houseme 73bde843d6 refactor(s3): consolidate semantic boundaries and remove s3-common (#3012)
* refactor(common): introduce rustfs-data-usage core crate

* refactor(concurrency): migrate workers crate into concurrency

* refactor(crypto): migrate appauth token APIs into crypto

* fix docs urls

* remove unused crate

* refactor(data-usage): switch consumers to rustfs-data-usage

* chore(fmt): apply cargo fmt and lockfile sync

* refactor(common): remove data_usage compatibility re-export

* refactor(capacity): move capacity_scope to object-capacity

* refactor(io-metrics): relocate internode metrics from common

* refactor(common): decouple scanner report from madmin

* chore(fmt): normalize import ordering after pre-commit

* refactor(s3): split s3 types and ops crates

* refactor(s3): centralize event version and safe parsing

* refactor(s3): add op-event compatibility guardrails

* refactor(s3): add runtime op-event mismatch observability

* refactor(s3): extract delete event mapping helper

* refactor(s3): extract put event mapping helper

* refactor(s3): consolidate remaining event semantic helpers

* refactor(s3): add op-event coverage checks and observability alerts

* refactor(s3-ops): consolidate op-event semantic mapping

* refactor(scanner): remove last_minute wrapper module

* refactor(scanner): consolidate duplicated data usage models
2026-05-19 12:50:25 +00:00

293 lines
10 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! MySQL notification target integration tests.
//!
//! These tests require a running MySQL 8.0+ or TiDB 8.5+ instance.
//! They are `#[ignore]` by default so CI never runs them. To run locally
//! (podman recommended; docker works too):
//!
//! ```bash
//! podman run -d --name rustfs-mysql-test -p 3306:3306 \
//! -e MYSQL_ROOT_PASSWORD=testpass -e MYSQL_DATABASE=testdb \
//! docker.io/library/mysql:8.0.36
//! ```
//!
//! Wait for MySQL to be ready (look for `ready for connections` in logs),
//! then set `RUSTFS_TEST_MYSQL_DSN` and run:
//!
//! ```bash
//! export RUSTFS_TEST_MYSQL_DSN="root:testpass@tcp(127.0.0.1:3306)/testdb"
//! cargo test -p rustfs-targets --test mysql_integration -- --ignored
//! ```
//!
//! Clean up:
//!
//! ```bash
//! podman rm -f rustfs-mysql-test
//! ```
use mysql_async::{Opts, OptsBuilder, Pool, SslOpts, prelude::Queryable};
use rustfs_targets::{Target, TargetError, target::mysql::*, target::*};
use std::env;
use std::sync::Arc;
use tempfile::TempDir;
use uuid::Uuid;
fn test_dsn() -> String {
env::var("RUSTFS_TEST_MYSQL_DSN").expect("RUSTFS_TEST_MYSQL_DSN must be set")
}
fn table_name(prefix: &str) -> String {
let suffix = Uuid::new_v4().simple().to_string();
format!("{prefix}_{}", &suffix[..16])
}
fn make_args(dsn: &str, table: &str, queue_dir: &str) -> MySqlArgs {
MySqlArgs {
enable: true,
dsn_string: dsn.to_string(),
table: table.to_string(),
format: "access".to_string(),
tls_ca: String::new(),
tls_client_cert: String::new(),
tls_client_key: String::new(),
queue_dir: queue_dir.to_string(),
queue_limit: 100,
max_open_connections: 2,
target_type: TargetType::NotifyEvent,
}
}
fn make_entity(bucket: &str, object: &str, event_name: rustfs_s3_types::EventName) -> EntityTarget<serde_json::Value> {
EntityTarget {
object_name: object.to_string(),
bucket_name: bucket.to_string(),
event_name,
data: serde_json::json!({"eventTime": "2026-05-03T10:00:00Z"}),
}
}
fn build_test_pool(dsn_string: &str) -> Pool {
let parsed = MySqlDsn::parse(dsn_string).expect("parse test DSN");
let mut builder = OptsBuilder::default()
.user(Some(parsed.user))
.pass(Some(parsed.password))
.ip_or_hostname(parsed.host)
.tcp_port(parsed.port)
.db_name(Some(parsed.database));
if parsed.tls {
if rustls::crypto::CryptoProvider::get_default().is_none() {
rustls::crypto::aws_lc_rs::default_provider().install_default().ok();
}
builder = builder.ssl_opts(Some(SslOpts::default()));
}
Pool::new(Opts::from(builder))
}
async fn drop_table(dsn: &str, table: &str) {
let pool = build_test_pool(dsn);
let mut conn = pool.get_conn().await.expect("get conn for drop table");
let _ = conn
.query_drop(format!("DROP TABLE IF EXISTS `{}`", table.replace('.', "`.`")))
.await;
}
#[ignore]
#[tokio::test]
async fn direct_write_and_read() {
let dsn = test_dsn();
let table = table_name("test_direct");
let target: MySqlTarget<serde_json::Value> =
MySqlTarget::new("direct".to_string(), make_args(&dsn, &table, "")).expect("create target");
target.init().await.expect("init");
let entity = make_entity("mybucket", "obj.txt", rustfs_s3_types::EventName::ObjectCreatedPut);
target.save(Arc::new(entity)).await.expect("save");
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
let rows: Vec<mysql_async::Row> = conn.query(format!("SELECT * FROM `{table}`")).await.expect("select");
assert_eq!(rows.len(), 1);
let data: String = mysql_async::from_value(rows[0].get(1).unwrap());
assert!(data.contains("mybucket"), "event_data should contain bucket name, got: {data}");
drop_table(&dsn, &table).await;
}
#[ignore]
#[tokio::test]
async fn delete_appends_row_does_not_remove_old() {
let dsn = test_dsn();
let table = table_name("test_delete");
let target: MySqlTarget<serde_json::Value> =
MySqlTarget::new("delete".to_string(), make_args(&dsn, &table, "")).expect("create target");
target.init().await.expect("init");
let put = make_entity("mybucket", "obj.txt", rustfs_s3_types::EventName::ObjectCreatedPut);
target.save(Arc::new(put)).await.expect("save put");
let delete = make_entity("mybucket", "obj.txt", rustfs_s3_types::EventName::ObjectRemovedDelete);
target.save(Arc::new(delete)).await.expect("save delete");
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
let rows: Vec<mysql_async::Row> = conn.query(format!("SELECT * FROM `{table}`")).await.expect("select");
assert_eq!(rows.len(), 2, "both PUT and DELETE should produce rows");
drop_table(&dsn, &table).await;
}
#[ignore]
#[tokio::test]
async fn queue_store_saves_entry_and_replays() {
let dsn = test_dsn();
let table = table_name("test_queue");
let tmpdir = TempDir::new().expect("temp dir");
let queue_dir = tmpdir.path().to_str().expect("valid path");
let target: MySqlTarget<serde_json::Value> =
MySqlTarget::new("queue".to_string(), make_args(&dsn, &table, queue_dir)).expect("create target");
let entity = make_entity("mybucket", "obj.txt", rustfs_s3_types::EventName::ObjectCreatedPut);
target.save(Arc::new(entity)).await.expect("save to queue");
let store = target.store().expect("store should exist");
assert_eq!(store.len(), 1, "one entry should be in queue");
// Init creates the table; no rows should exist yet
target.init().await.expect("init");
{
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
let rows: Vec<mysql_async::Row> = conn.query(format!("SELECT * FROM `{table}`")).await.expect("select");
assert_eq!(rows.len(), 0, "no row should exist before replay");
}
for key in store.list() {
target.send_from_store(key).await.expect("replay should succeed");
}
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
let rows: Vec<mysql_async::Row> = conn.query(format!("SELECT * FROM `{table}`")).await.expect("select");
assert_eq!(rows.len(), 1, "one row should exist after replay");
assert_eq!(store.len(), 0, "queue should be empty after replay");
drop_table(&dsn, &table).await;
}
#[ignore]
#[tokio::test]
async fn duplicate_replay_produces_duplicate_rows() {
let dsn = test_dsn();
let table = table_name("test_dupe");
let tmpdir = TempDir::new().expect("temp dir");
let queue_dir = tmpdir.path().to_str().expect("valid path");
let target: MySqlTarget<serde_json::Value> =
MySqlTarget::new("dupe".to_string(), make_args(&dsn, &table, queue_dir)).expect("create target");
let entity = make_entity("mybucket", "obj.txt", rustfs_s3_types::EventName::ObjectCreatedPut);
target.save(Arc::new(entity)).await.expect("save to queue");
target.init().await.expect("init");
let store = target.store().expect("store should exist");
let keys: Vec<_> = store.list();
for key in &keys {
let raw = store.get_raw(key).expect("get raw");
let queued = QueuedPayload::decode(&raw).expect("decode");
// Replay twice: duplicate rows are expected (at-least-once)
for _ in 0..2 {
target
.send_raw_from_store(key.clone(), queued.body.clone(), queued.meta.clone())
.await
.expect("replay");
}
let _ = store.del(key);
}
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
let rows: Vec<mysql_async::Row> = conn.query(format!("SELECT * FROM `{table}`")).await.expect("select");
assert_eq!(rows.len(), 2, "duplicate replay should produce 2 rows");
drop_table(&dsn, &table).await;
}
#[ignore]
#[tokio::test]
async fn incompatible_schema_init_fails() {
let dsn = test_dsn();
let table = table_name("test_schema");
{
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
conn.query_drop(format!("CREATE TABLE IF NOT EXISTS `{table}` (wrong_col INT NOT NULL)"))
.await
.expect("create incompatible table");
}
let target = MySqlTarget::<serde_json::Value>::new("schema".to_string(), make_args(&dsn, &table, "")).expect("create target");
let result = target.init().await;
match result {
Err(TargetError::Initialization(msg)) => {
assert!(
msg.contains("event_time") || msg.contains("event_data"),
"error should mention missing columns, got: {msg}"
);
}
other => panic!("expected Initialization error, got {:?}", other),
}
drop_table(&dsn, &table).await;
}
#[ignore]
#[tokio::test]
async fn check_mysql_server_available_succeeds_against_existing_table() {
let dsn = test_dsn();
let table = table_name("test_check");
{
let pool = build_test_pool(&dsn);
let mut conn = pool.get_conn().await.expect("get conn");
conn.query_drop(format!(
"CREATE TABLE `{table}` (event_time DATETIME(6) NOT NULL, event_data JSON NOT NULL)"
))
.await
.expect("create table");
}
let args = make_args(&dsn, &table, "");
rustfs_targets::check_mysql_server_available(&args)
.await
.expect("connectivity probe should succeed against existing table");
drop_table(&dsn, &table).await;
}