// Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you 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 client TRANSACTION integration tests, run through brpc's PUBLIC API // against a REAL mysqld. // // Each TEST_F drives a transaction scenario end to end: // * brpc::Channel(protocol="mysql", connection_type="pooled", // auth=MysqlAuthenticator) -> a live connection to the server; // * brpc::NewMysqlTransaction(channel, opts) -> a connection-pinned // transaction handle (START TRANSACTION on a dedicated socket); // * MysqlRequest(tx).Query(...) + channel.CallMethod(...) -> statements // INSIDE the transaction (same pinned socket); // * MysqlRequest().Query(...) on the SAME channel -> a SECOND connection // from the pool, used as an independent observer to prove isolation // (uncommitted rows are invisible until commit()); // * tx->commit() / tx->rollback() -> terminate the transaction. // // Because transactions, simple SELECTs and DML all flow through the same // COM_QUERY text protocol, these tests also cover simple-query execution // and text-result parsing (column metadata + row field decoding). // // Harness (server spawn / skip convention, -mysql_use_running_server and // -mysql_host/-port/-user/-password gflags) follows // test/mysql/brpc_mysql_auth_handshake_unittest.cpp and, transitively, // test/brpc_redis_unittest.cpp's which-then-spawn pattern. When mysqld is // absent every test GTEST_SKIP()s, so the file is CI-safe with no server. #include #include #include #include #include #include #include #include #include #include #include "brpc/channel.h" #include "brpc/policy/mysql/mysql.h" #include "brpc/policy/mysql/mysql_transaction.h" #include "brpc/policy/mysql/mysql_authenticator.h" #include "butil/logging.h" // These gflags are intentionally re-declared here (not shared with the auth // unittest): the CMake glob at test/CMakeLists.txt builds each // brpc_mysql_*_unittest.cpp into its OWN executable, so there is no symbol // collision across test binaries. DEFINE_bool(mysql_use_running_server, false, "Use an already-running MySQL server instead of spawning a " "throwaway one; the running server is neither started nor stopped " "by the test."); DEFINE_string(mysql_host, "127.0.0.1", "Host of the running MySQL server " "(only with -mysql_use_running_server)."); DEFINE_int32(mysql_port, 13306, "TCP port of the MySQL server (used for both the running server " "and the spawned throwaway server)."); DEFINE_string(mysql_user, "root", "Login user for the transaction tests."); DEFINE_string(mysql_password, "", "Password for -mysql_user (empty for the spawned server)."); DEFINE_string(mysql_schema, "brpc_txn_test", "Schema (database) the transaction tests create and use."); namespace { // Throwaway-server harness (mirrors brpc_mysql_auth_handshake_unittest.cpp, // which mirrors brpc_redis_unittest.cpp). >0: forked pid; -2: external // running server reachable; -1: no server -> tests skip. #define MYSQLD_BIN "mysqld" static pthread_once_t s_start_once = PTHREAD_ONCE_INIT; static pid_t s_mysqld_pid = -1; static std::string s_host = "127.0.0.1"; static int s_port = 13306; static std::string s_user = "root"; static std::string s_password; static std::string TestDataDir() { char cwd[1024]; if (getcwd(cwd, sizeof(cwd)) == NULL) { return std::string("/tmp/mysql_txn_data_for_test"); } return std::string(cwd) + "/mysql_txn_data_for_test"; } static void RemoveMysqlServer() { if (s_mysqld_pid > 0) { puts("[Stopping mysqld]"); char cmd[1280]; snprintf(cmd, sizeof(cmd), "kill %d", s_mysqld_pid); CHECK(0 == system(cmd)); usleep(500000); snprintf(cmd, sizeof(cmd), "rm -rf '%s'", TestDataDir().c_str()); CHECK(0 == system(cmd)); } } // Raw TCP probe to detect server readiness; returns fd (caller closes) or -1. static int ProbeMysql() { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { return -1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(s_port)); addr.sin_addr.s_addr = inet_addr(s_host.c_str()); if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -1; } return fd; } static void RunMysqlServer() { if (FLAGS_mysql_use_running_server) { s_host = FLAGS_mysql_host; s_port = FLAGS_mysql_port; s_user = FLAGS_mysql_user; s_password = FLAGS_mysql_password; printf("[Using running mysqld at %s:%d as user '%s']\n", s_host.c_str(), s_port, s_user.c_str()); int fd = ProbeMysql(); if (fd >= 0) { close(fd); s_mysqld_pid = -2; } else { printf("Cannot reach running mysqld at %s:%d, tests will skip\n", s_host.c_str(), s_port); } return; } if (system("which " MYSQLD_BIN) != 0) { puts("Fail to find " MYSQLD_BIN ", transaction tests will be skipped"); return; } s_host = "127.0.0.1"; s_port = FLAGS_mysql_port; s_user = "root"; s_password.clear(); const std::string datadir = TestDataDir(); char cmd[2048]; snprintf(cmd, sizeof(cmd), "rm -rf '%s' && mkdir -p '%s'", datadir.c_str(), datadir.c_str()); if (system(cmd) != 0) { puts("Fail to create datadir, transaction tests will be skipped"); return; } snprintf(cmd, sizeof(cmd), MYSQLD_BIN " --initialize-insecure --datadir='%s'" " --log-error='%s/init.err'", datadir.c_str(), datadir.c_str()); if (system(cmd) != 0) { puts("Fail to initialize mysqld datadir, tests will be skipped"); snprintf(cmd, sizeof(cmd), "rm -rf '%s'", datadir.c_str()); CHECK(0 == system(cmd)); return; } atexit(RemoveMysqlServer); s_mysqld_pid = fork(); if (s_mysqld_pid < 0) { puts("Fail to fork"); exit(1); } else if (s_mysqld_pid == 0) { puts("[Starting mysqld]"); char port_arg[32]; snprintf(port_arg, sizeof(port_arg), "--port=%d", FLAGS_mysql_port); const std::string datadir_arg = "--datadir=" + datadir; const std::string socket_arg = "--socket=" + datadir + "/mysqld.sock"; const std::string pidfile_arg = "--pid-file=" + datadir + "/mysqld.pid"; const std::string logerr_arg = "--log-error=" + datadir + "/mysqld.err"; char* const argv[] = { (char*)MYSQLD_BIN, (char*)datadir_arg.c_str(), (char*)port_arg, (char*)socket_arg.c_str(), (char*)pidfile_arg.c_str(), (char*)logerr_arg.c_str(), (char*)"--mysqlx=OFF", (char*)"--bind-address=127.0.0.1", NULL}; if (execvp(MYSQLD_BIN, argv) < 0) { puts("Fail to run " MYSQLD_BIN); exit(1); } } // Wait for TCP readiness (fresh tablespace recovery), then create a // password account so the caching_sha2 client can authenticate over TCP // exactly like the running-server mode. root keeps its empty password on // the unix socket; we exercise the spawned server as empty-password root. for (int i = 0; i < 300; ++i) { int fd = ProbeMysql(); if (fd >= 0) { close(fd); return; } usleep(100000); } puts("mysqld did not become ready, transaction tests will be skipped"); s_mysqld_pid = -1; } // Small helpers over the brpc MySQL public API. // Runs |sql| outside any transaction on |channel| (a fresh pooled // connection). Returns false on transport failure. static bool RunPlain(brpc::Channel& channel, const std::string& sql, brpc::MysqlResponse* resp) { brpc::MysqlRequest req; if (!req.Query(sql)) { return false; } brpc::Controller cntl; channel.CallMethod(NULL, &cntl, &req, resp, NULL); return !cntl.Failed(); } // Runs |sql| INSIDE transaction |tx| (its pinned connection). Returns false // on transport failure. static bool RunInTx(brpc::Channel& channel, const brpc::MysqlTransaction* tx, const std::string& sql, brpc::MysqlResponse* resp) { brpc::MysqlRequest req(tx); if (!req.Query(sql)) { return false; } brpc::Controller cntl; channel.CallMethod(NULL, &cntl, &req, resp, NULL); return !cntl.Failed(); } // Convenience: expects a single OK reply for a DML/DDL statement. static ::testing::AssertionResult ExpectOk(const brpc::MysqlResponse& resp) { if (resp.reply_size() < 1) { return ::testing::AssertionFailure() << "no reply"; } const brpc::MysqlReply& r = resp.reply(0); if (r.is_error()) { return ::testing::AssertionFailure() << "ERR " << r.error().errcode() << ": " << r.error().msg().as_string(); } if (!r.is_ok()) { return ::testing::AssertionFailure() << "reply is not OK, type=" << r.type(); } return ::testing::AssertionSuccess(); } // Returns the row count of the FIRST reply, asserting it is a result set. // On any non-resultset reply returns -1 (so callers can fail clearly). static int64_t ResultRowCount(const brpc::MysqlResponse& resp) { if (resp.reply_size() < 1) { return -1; } const brpc::MysqlReply& r = resp.reply(0); if (!r.is_resultset()) { return -1; } return static_cast(r.row_count()); } // Fixture: one channel + a scratch table per test (built in SetUp, dropped in // TearDown). InnoDB so DML is transactional. class MysqlTxnIntegrationTest : public testing::Test { protected: static bool NoServer() { return s_mysqld_pid == -1; } void SetUp() override { pthread_once(&s_start_once, RunMysqlServer); if (NoServer()) { GTEST_SKIP() << "no mysqld available; skipping transaction tests"; } // Authenticator carries user/password and the working schema. An // empty schema is created first over a schema-less channel. ASSERT_TRUE(InitChannel(&_setup_channel, /*schema=*/"")); brpc::MysqlResponse resp; ASSERT_TRUE(RunPlain(_setup_channel, "CREATE DATABASE IF NOT EXISTS " + FLAGS_mysql_schema, &resp)); ASSERT_TRUE(InitChannel(&_channel, FLAGS_mysql_schema)); ASSERT_TRUE(RunPlain(_channel, "DROP TABLE IF EXISTS " + Table(), &resp)); ASSERT_TRUE(ExpectOk(resp)) << "drop pre-existing scratch table"; ASSERT_TRUE(RunPlain(_channel, "CREATE TABLE " + Table() + " (id INT PRIMARY KEY, name VARCHAR(32)) " "ENGINE=InnoDB", &resp)); ASSERT_TRUE(ExpectOk(resp)) << "create scratch table"; } void TearDown() override { if (NoServer()) { return; } brpc::MysqlResponse resp; RunPlain(_channel, "DROP TABLE IF EXISTS " + Table(), &resp); } // Pooled channel is required so a transaction can pin its own dedicated // connection while the test issues independent observer queries on others. bool InitChannel(brpc::Channel* channel, const std::string& schema) { _auth.reset(new brpc::policy::MysqlAuthenticator(s_user, s_password, schema)); brpc::ChannelOptions options; options.protocol = "mysql"; options.connection_type = "pooled"; options.auth = _auth.get(); options.timeout_ms = 5000; options.max_retry = 0; char addr[128]; snprintf(addr, sizeof(addr), "%s:%d", s_host.c_str(), s_port); return channel->Init(addr, &options) == 0; } std::string Table() const { return "txn_scratch"; } brpc::Channel _setup_channel; brpc::Channel _channel; // Authenticator must outlive the channels that point at it. std::unique_ptr _auth; }; // Test cases. Each fat test chains several transactional behaviors so a single // TEST_F validates a whole group of related transaction guarantees together. // Transaction lifecycle: commit publishes a row to other connections, and a // rolled-back insert as well as a rolled-back delete leave the table exactly as // it was before the transaction started. TEST_F(MysqlTxnIntegrationTest, CommitPublishesRollbackRestores) { brpc::MysqlResponse resp; // 1) committed INSERT must be visible on a fresh connection afterwards. { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL) << "failed to start transaction"; ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (3107, 'quill')", &resp)); EXPECT_TRUE(ExpectOk(resp)); EXPECT_EQ(resp.reply(0).ok().affect_row(), 1u); ASSERT_TRUE(tx->commit()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id, name FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1); { const brpc::MysqlReply& r = resp.reply(0); ASSERT_TRUE(r.is_resultset()); ASSERT_EQ(r.row_count(), 1u); const brpc::MysqlReply::Row& row = r.next(); ASSERT_EQ(row.field_count(), 2u); EXPECT_EQ(row.field(0).sinteger(), 3107); EXPECT_EQ(row.field(1).string().as_string(), "quill"); } // 2) a rolled-back INSERT must leave no trace (still exactly one row). { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (5288, 'brindle')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(tx->rollback()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1) << "rolled-back insert must vanish"; // 3) a rolled-back DELETE of the committed row must restore it. { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); ASSERT_TRUE(RunInTx(_channel, tx.get(), "DELETE FROM " + Table() + " WHERE id = 3107", &resp)); EXPECT_TRUE(ExpectOk(resp)); EXPECT_EQ(resp.reply(0).ok().affect_row(), 1u); ASSERT_TRUE(tx->rollback()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1) << "rolled-back delete must restore row"; { const brpc::MysqlReply& r = resp.reply(0); ASSERT_TRUE(r.is_resultset()); ASSERT_EQ(r.row_count(), 1u); EXPECT_EQ(r.next().field(0).sinteger(), 3107); } } // Isolation in both directions on the same open transaction: the transaction // reads its own not-yet-committed write on its pinned connection, while an // independent pooled connection sees nothing until the rollback. TEST_F(MysqlTxnIntegrationTest, OwnWriteVisibleOthersIsolated) { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); brpc::MysqlResponse resp; ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (6741, 'tangle')", &resp)); EXPECT_TRUE(ExpectOk(resp)); // Same pinned connection: must read its own uncommitted row. ASSERT_TRUE(RunInTx(_channel, tx.get(), "SELECT name FROM " + Table() + " WHERE id = 6741", &resp)); EXPECT_EQ(ResultRowCount(resp), 1); { const brpc::MysqlReply& r = resp.reply(0); ASSERT_TRUE(r.is_resultset()); ASSERT_EQ(r.row_count(), 1u); EXPECT_EQ(r.next().field(0).string().as_string(), "tangle"); } // A different pooled connection must NOT see the uncommitted write. ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 0) << "uncommitted write leaked to another connection"; ASSERT_TRUE(tx->rollback()); // After rollback nothing remains anywhere. ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 0); } // Autocommit behavior, both states in one test: with autocommit on (the // default) a bare INSERT is immediately durable on a new connection; toggling // autocommit off on a pinned connection turns a later INSERT into pending work // that ROLLBACK discards. TEST_F(MysqlTxnIntegrationTest, AutocommitOnDurableOffRollbackable) { brpc::MysqlResponse resp; // autocommit ON: immediate durability. ASSERT_TRUE(RunPlain(_channel, "INSERT INTO " + Table() + " VALUES (4419, 'amber')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1); // autocommit OFF on a pinned connection: a new INSERT is pending and a // ROLLBACK drops only it, leaving the earlier durable row in place. { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); ASSERT_TRUE(RunInTx(_channel, tx.get(), "SET autocommit = 0", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (8053, 'frost')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(tx->rollback()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1) << "autocommit=0 + rollback should drop only the pending insert"; EXPECT_EQ(resp.reply(0).next().field(0).sinteger(), 4419); } // Multi-statement transactional grouping plus partial undo: two inserts grouped // under one transaction become visible together only after commit, and within a // second transaction a SAVEPOINT lets a later insert be peeled back while the // pre-savepoint work survives the final commit. TEST_F(MysqlTxnIntegrationTest, GroupedInsertsThenSavepointPartialUndo) { brpc::MysqlResponse resp; // Two inserts under one transaction: invisible until commit, then both show. { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (211, 'one')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (733, 'two')", &resp)); EXPECT_TRUE(ExpectOk(resp)); // Not yet visible to a separate connection. ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 0); ASSERT_TRUE(tx->commit()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 2) << "both grouped inserts visible"; // Start fresh for the savepoint half. ASSERT_TRUE(RunPlain(_channel, "DELETE FROM " + Table(), &resp)); ASSERT_TRUE(ExpectOk(resp)); // SAVEPOINT then ROLLBACK TO it: pre-savepoint row kept, post dropped. { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (901, 'kept')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunInTx(_channel, tx.get(), "SAVEPOINT mark1", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (902, 'gone')", &resp)); EXPECT_TRUE(ExpectOk(resp)); ASSERT_TRUE(RunInTx(_channel, tx.get(), "ROLLBACK TO SAVEPOINT mark1", &resp)); EXPECT_TRUE(ExpectOk(resp)); // Inside the txn only the pre-savepoint row remains. ASSERT_TRUE(RunInTx(_channel, tx.get(), "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1); ASSERT_TRUE(tx->commit()); } ASSERT_TRUE(RunPlain(_channel, "SELECT id FROM " + Table(), &resp)); EXPECT_EQ(ResultRowCount(resp), 1) << "only the kept row should persist"; { const brpc::MysqlReply& r = resp.reply(0); ASSERT_TRUE(r.is_resultset()); ASSERT_EQ(r.row_count(), 1u); EXPECT_EQ(r.next().field(0).sinteger(), 901); } } // Error surfaces from within a transaction: a duplicate-primary-key insert // returns an ERR reply (and the transaction still rolls back cleanly), and a // write attempted in a read-only transaction is likewise rejected with ERR. TEST_F(MysqlTxnIntegrationTest, DuplicateKeyAndReadOnlyWriteReportErr) { brpc::MysqlResponse resp; // Seed a committed row so the in-txn insert collides on the primary key. ASSERT_TRUE(RunPlain(_channel, "INSERT INTO " + Table() + " VALUES (1505, 'seed')", &resp)); ASSERT_TRUE(ExpectOk(resp)); { brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, brpc::MysqlTransactionOptions()); ASSERT_TRUE(tx != NULL); // Duplicate-key insert -> ERR packet (errno 1062, ER_DUP_ENTRY). ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (1505, 'clash')", &resp)); ASSERT_GE(resp.reply_size(), 1u); const brpc::MysqlReply& r = resp.reply(0); EXPECT_TRUE(r.is_error()) << "duplicate key should yield an ERR reply"; if (r.is_error()) { EXPECT_EQ(r.error().errcode(), 1062) << "expected ER_DUP_ENTRY (1062)"; } ASSERT_TRUE(tx->rollback()); } // A read-only transaction must reject a write. { brpc::MysqlTransactionOptions opts; opts.readonly = true; brpc::MysqlTransactionUniquePtr tx = brpc::NewMysqlTransaction(_channel, opts); ASSERT_TRUE(tx != NULL) << "failed to start read-only transaction"; ASSERT_TRUE(RunInTx(_channel, tx.get(), "INSERT INTO " + Table() + " VALUES (1777, 'nope')", &resp)); ASSERT_GE(resp.reply_size(), 1u); const brpc::MysqlReply& r = resp.reply(0); EXPECT_TRUE(r.is_error()) << "write in a read-only transaction should be rejected with ERR"; if (r.is_error()) { // ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION == 1792. EXPECT_EQ(r.error().errcode(), 1792) << "expected read-only-transaction error (1792)"; } ASSERT_TRUE(tx->rollback()); } } } // namespace