foundationdb/fdbserver/workloads/TxnTimeout.cpp

327 lines
12 KiB
C++

/*
* TxnTimeout.cpp
*
* This workload validates that FoundationDB's transaction timeout mechanisms work correctly
* by creating long-running transactions that intentionally approach (but don't exceed) the
* configured transaction lifetime limits.
*
* Test Strategy:
* 1. Configures transaction lifetime via MAX_READ/WRITE_TRANSACTION_LIFE_VERSIONS knobs
* 2. Creates transactions that perform read-modify-write operations with artificial delays
* 3. Ensures transactions complete successfully when staying within timeout bounds
* 4. Detects and reports any errors
*
* The workload runs with failure injection disabled to ensure consistent timeout behavior
* and verify that the timeout enforcement is working as designed without interference from
* other failure modes.
*/
#include "fdbclient/FDBTypes.h"
#include "fdbrpc/PerfMetric.h"
#include "fdbserver/core/Knobs.h"
#include "fdbserver/core/ServerDBInfo.h"
#include "fdbserver/tester/workloads.h"
#include "flow/Buggify.h"
#include "flow/Error.h"
#include "flow/Optional.h"
#include "flow/Trace.h"
#include "flow/flow.h"
#include "flow/genericactors.actor.h"
struct TxnTimeout : TestWorkload {
static constexpr auto NAME = "TxnTimeout";
// Configuration parameters
double testDuration{ 0.0 }; // Total duration of the test in seconds
int actorsPerClient{ 0 }; // Number of concurrent transaction actors per test client
int nodeCountPerClientPerActor{ 0 }; // Number of unique keys each actor operates on
double txnMinDuration{ 0.0 }; // Target minimum duration for each transaction (seconds)
// Metrics tracked during test execution
int txnsTotal{ 0 }; // Total number of transactions attempted
int txnsSucceeded{ 0 }; // Number of transactions that completed successfully
int txnsFailed{ 0 }; // Number of transactions that failed with unexpected errors
explicit TxnTimeout(const WorkloadContext& wctx) : TestWorkload(wctx) {
// Parse workload configuration from TOML test definition
testDuration = getOption(options, "testDuration"_sr, 120.0);
actorsPerClient = getOption(options, "actorsPerClient"_sr, 1);
nodeCountPerClientPerActor = getOption(options, "nodeCountPerClientPerActor"_sr, 100);
txnMinDuration = getOption(options, "txnMinDuration"_sr, 5.0);
}
Future<Void> setup(const Database& db) override {
TraceEvent("TxnTimeoutSetup")
.detail("TestDuration", testDuration)
.detail("ActorsPerClient", actorsPerClient)
.detail("NodeCountPerClientPerActor", nodeCountPerClientPerActor)
.detail("TxnMinDuration", txnMinDuration)
.detail("MaxReadTxnLifeVersions", SERVER_KNOBS->MAX_READ_TRANSACTION_LIFE_VERSIONS)
.detail("MaxWriteTxnLifeVersions", SERVER_KNOBS->MAX_WRITE_TRANSACTION_LIFE_VERSIONS);
return Void();
}
static bool runTest() { return g_network->isSimulated() && !isGeneralBuggifyEnabled(); }
Future<Void> start(const Database& db) override {
if (!runTest()) {
return Void();
}
// Let in-flight transactions drain so the final success count includes every attempted transaction.
return reportErrors(workload(this, db), "TxnTimeoutError");
}
Future<bool> check(const Database& db) override {
if (!runTest()) {
return true;
}
// Test succeeds if all transactions completed without unexpected timeout failures
if (txnsFailed > 0 || txnsSucceeded == 0 || txnsSucceeded != txnsTotal) {
TraceEvent(SevError, "TxnTimeoutCheckFailure")
.detail("TxnsSucceeded", txnsSucceeded)
.detail("TxnsFailed", txnsFailed)
.detail("TxnsTotal", txnsTotal)
.detail("Reason",
txnsFailed > 0 ? "UnexpectedTimeoutErrors"
: (txnsSucceeded == 0 ? "NoSuccessfulTransactions" : "CountMismatch"));
return false;
}
return true;
}
void getMetrics(std::vector<PerfMetric>& m) override {}
// Disable all failure injection to ensure clean timeout behavior testing
void disableFailureInjectionWorkloads(std::set<std::string>& out) const override { out.insert("all"); }
// Generates a consistent key format for the workload
// Format: "txntimeout_c{clientId}_a{actorIdx}_n{nodeIdx}"
// This ensures the same key is used during populate and transaction phases
static Key makeKey(int clientId, int actorIdx, int nodeIdx) {
return Key(format("txntimeout_c%d_a%d_n%d", clientId, actorIdx, nodeIdx));
}
// Initializes the database with test data for each actor to operate on
// Each actor creates nodeCountPerClientPerActor keys initialized to value "0"
// Keys are batched into transactions for efficiency
Future<Void> populateDatabase(Database db, int actorIdx) {
int nodeIdx = 0;
// Batch size is 1/4 of total keys, resulting in 4 batches per actor
int batchSize = std::max(1, nodeCountPerClientPerActor / 4);
while (nodeIdx < nodeCountPerClientPerActor) {
Transaction tr(db);
while (true) {
Error err;
try {
// Batch up to batchSize keys in a single transaction
int batchEnd = std::min(nodeIdx + batchSize, nodeCountPerClientPerActor);
for (int i = nodeIdx; i < batchEnd; i++) {
Key key = makeKey(clientId, actorIdx, i);
tr.set(key, "0"_sr);
}
co_await tr.commit();
nodeIdx = batchEnd;
break;
} catch (Error& e) {
err = e;
}
co_await tr.onError(err);
}
}
TraceEvent("TxnTimeoutPopulateComplete")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("KeysCreated", nodeIdx);
}
// Runs database population concurrently across actors and clients
Future<Void> populateDatabaseAllActors(Database db) {
std::vector<Future<Void>> populationActors;
for (int actorIdx = 0; actorIdx < actorsPerClient; ++actorIdx) {
populationActors.push_back(populateDatabase(db, actorIdx));
}
co_await waitForAll(populationActors);
TraceEvent("TxnTimeoutPopulateAllComplete").detail("ClientId", clientId);
}
/*
* Transaction client actor that performs read-modify-write operations with intentional delays.
*
* Each transaction:
* 1. Gets a read version and reads a value from the database
* 2. Waits until txnMinDuration seconds have elapsed (artificially extending the transaction)
* 3. Writes an incremented value back
* 4. Commits the transaction
*
* The goal is to test that transactions can stay open for txnMinDuration seconds
* without hitting transaction_too_old errors, as long as that duration is within
* the configured MAX_*_TRANSACTION_LIFE_VERSIONS bounds.
*
* Error Handling:
* - Expected errors during recovery (future_version, commit_unknown_result, process_behind) are tolerated
* - Version jumps due to recovery (>MAX_WRITE_TRANSACTION_LIFE_VERSIONS) are tolerated
* - Any other transaction_too_old or similar timeout errors are counted as failures
*/
Future<Void> txnClient(Database db, int actorIdx) {
int nodeIdx = 0;
double workloadStartTime = now();
// Run transactions for 80% of test duration to allow time for cleanup
double runDuration = testDuration * 0.8;
while (true) {
// Cycle through all keys for this actor
if (nodeIdx == nodeCountPerClientPerActor) {
nodeIdx = 0;
}
// Stop when we've reached the target run duration
if (now() - workloadStartTime > runDuration) {
TraceEvent("TxnTimeoutClientComplete")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("Duration", now() - workloadStartTime);
break;
}
Transaction tr(db);
Version readVersion = 0;
double txnStartTime = now();
txnsTotal++;
while (true) {
Error caughtErr;
try {
// Generate the same key pattern as in populate phase
Key key = makeKey(clientId, actorIdx, nodeIdx);
// Get read version and read the current value
double readStartTime = now();
Version rv = co_await tr.getReadVersion();
readVersion = rv;
Optional<Value> val = co_await tr.get(key);
double readDuration = now() - readStartTime;
// Artificial delay to extend transaction lifetime to target duration
// This is the core of the test: keeping transactions open longer than the usual 5 seconds
if (txnMinDuration > readDuration) {
co_await delay(txnMinDuration - readDuration);
}
// Perform write operation (increment counter)
int currentVal = std::stoi(val.get().toString());
std::string newVal = std::to_string(currentVal + 1);
tr.set(key, StringRef(newVal));
// Commit and measure total transaction latency
co_await tr.commit();
double txnLatency = now() - txnStartTime;
txnsSucceeded++;
TraceEvent("TxnTimeoutTxnSuccess")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("Key", key)
.detail("OldValue", currentVal)
.detail("TxnLatency", txnLatency)
.detail("ReadVersion", readVersion);
nodeIdx++;
break;
} catch (Error& e) {
caughtErr = e;
}
Error err = caughtErr;
bool isExpectedError = err.code() == error_code_future_version ||
err.code() == error_code_commit_unknown_result ||
err.code() == error_code_process_behind;
TraceEvent(isExpectedError ? SevInfo : SevWarn, "TxnTimeoutTxnError")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("RecoveryState", dbInfo->get().recoveryState)
.detail("ReadVersion", readVersion)
.errorUnsuppressed(err);
co_await tr.onError(err);
// Check if version jumped significantly (e.g stale read version, recovery)
Transaction rvTr(db);
Version newReadVersion = co_await rvTr.getReadVersion();
// The version delta is "best guess" because the newReadVersion could itself be stale, therefore
// the delta could be smaller than (sequencer commit version - readVersion)
Version versionDelta = newReadVersion - readVersion;
const bool isHighVersionJump = versionDelta > SERVER_KNOBS->MAX_WRITE_TRANSACTION_LIFE_VERSIONS;
const double txnDuration = now() - txnStartTime;
const bool tooMuchTimeHasPassed =
txnDuration >
((double)SERVER_KNOBS->MAX_WRITE_TRANSACTION_LIFE_VERSIONS / SERVER_KNOBS->VERSIONS_PER_SECOND);
if (!isExpectedError && !isHighVersionJump && !tooMuchTimeHasPassed) {
txnsFailed++;
TraceEvent(SevError, "TxnTimeoutUnexpectedFailure")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("OldReadVersion", readVersion)
.detail("NewReadVersion", newReadVersion)
.detail("VersionDelta", versionDelta)
.detail("TxnDuration", txnDuration)
.errorUnsuppressed(err);
} else {
TraceEvent("TxnTimeoutExpectedFailure")
.detail("ClientId", clientId)
.detail("ActorIdx", actorIdx)
.detail("OldReadVersion", readVersion)
.detail("NewReadVersion", newReadVersion)
.detail("VersionDelta", versionDelta)
.detail("IsExpectedError", isExpectedError)
.detail("IsHighVersionJump", isHighVersionJump);
}
txnStartTime = now();
}
}
}
/*
* Main workload orchestration.
*
* Phase 1: Populate the database with initial test data
* Phase 2: Run concurrent transaction clients that test timeout behavior
* Phase 3: Report final metrics
*/
Future<Void> workload(TxnTimeout* self, Database db) {
TraceEvent("TxnTimeoutWorkloadStart")
.detail("ClientId", self->clientId)
.detail("TestDuration", self->testDuration)
.detail("ActorsPerClient", self->actorsPerClient);
// Phase 1: Initialize database with test data
co_await populateDatabaseAllActors(db);
// Phase 2: Run transaction clients that test timeout behavior
std::vector<Future<Void>> txnClients;
for (int actorIdx = 0; actorIdx < self->actorsPerClient; ++actorIdx) {
txnClients.emplace_back(txnClient(db, actorIdx));
}
co_await waitForAll(txnClients);
// Phase 3: Report final metrics
TraceEvent("TxnTimeoutWorkloadComplete")
.detail("ClientId", self->clientId)
.detail("TxnsSucceeded", self->txnsSucceeded)
.detail("TxnsFailed", self->txnsFailed)
.detail("TxnsTotal", self->txnsTotal)
.detail("SuccessRate", self->txnsTotal > 0 ? (double)self->txnsSucceeded / self->txnsTotal : 0.0);
}
};
WorkloadFactory<TxnTimeout> TxnTimeoutWorkloadFactory;