| // 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 <gflags/gflags.h> |
| #include <gtest/gtest.h> |
| |
| #include <arpa/inet.h> |
| #include <netinet/in.h> |
| #include <sys/socket.h> |
| #include <unistd.h> |
| |
| #include <cstdio> |
| #include <cstdlib> |
| #include <pthread.h> |
| #include <string> |
| |
| #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<uint16_t>(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<int64_t>(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<brpc::policy::MysqlAuthenticator> _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 |