mirror of https://github.com/apache/kafka.git
KAFKA-12888; Add transaction tool from KIP-664 (#10814)
This patch adds the transaction tool specified in KIP-664: https://cwiki.apache.org/confluence/display/KAFKA/KIP-664%3A+Provide+tooling+to+detect+and+abort+hanging+transactions. This includes all of the logic for describing transactional state and for aborting transactions. The only thing that is left out is the `--find-hanging` implementation, which will be left for a subsequent patch. Reviewers: Boyang Chen <boyang@apache.org>, David Jacot <djacot@confluent.io>
This commit is contained in:
parent
c3475081c5
commit
fce771579c
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
# 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.
|
||||
|
||||
exec $(dirname $0)/kafka-run-class.sh org.apache.kafka.tools.TransactionsCommand "$@"
|
|
@ -0,0 +1,17 @@
|
|||
@echo off
|
||||
rem Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
rem contributor license agreements. See the NOTICE file distributed with
|
||||
rem this work for additional information regarding copyright ownership.
|
||||
rem The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
rem (the "License"); you may not use this file except in compliance with
|
||||
rem the License. You may obtain a copy of the License at
|
||||
rem
|
||||
rem http://www.apache.org/licenses/LICENSE-2.0
|
||||
rem
|
||||
rem Unless required by applicable law or agreed to in writing, software
|
||||
rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
rem See the License for the specific language governing permissions and
|
||||
rem limitations under the License.
|
||||
|
||||
"%~dp0kafka-run-class.bat" org.apache.kafka.tools.TransactionsCommand %*
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.kafka.tools;
|
||||
|
||||
import net.sourceforge.argparse4j.inf.Argument;
|
||||
import net.sourceforge.argparse4j.inf.ArgumentAction;
|
||||
import net.sourceforge.argparse4j.inf.ArgumentParser;
|
||||
import org.apache.kafka.common.utils.AppInfoParser;
|
||||
import org.apache.kafka.common.utils.Exit;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
class PrintVersionAndExitAction implements ArgumentAction {
|
||||
|
||||
@Override
|
||||
public void run(
|
||||
ArgumentParser parser,
|
||||
Argument arg,
|
||||
Map<String, Object> attrs,
|
||||
String flag,
|
||||
Object value
|
||||
) {
|
||||
String version = AppInfoParser.getVersion();
|
||||
String commitId = AppInfoParser.getCommitId();
|
||||
System.out.println(version + " (Commit:" + commitId + ")");
|
||||
Exit.exit(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Argument arg) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean consumeArgument() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,623 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.kafka.tools;
|
||||
|
||||
import net.sourceforge.argparse4j.ArgumentParsers;
|
||||
import net.sourceforge.argparse4j.inf.ArgumentGroup;
|
||||
import net.sourceforge.argparse4j.inf.ArgumentParser;
|
||||
import net.sourceforge.argparse4j.inf.ArgumentParserException;
|
||||
import net.sourceforge.argparse4j.inf.Namespace;
|
||||
import net.sourceforge.argparse4j.inf.Subparser;
|
||||
import net.sourceforge.argparse4j.inf.Subparsers;
|
||||
import org.apache.kafka.clients.admin.AbortTransactionSpec;
|
||||
import org.apache.kafka.clients.admin.Admin;
|
||||
import org.apache.kafka.clients.admin.AdminClientConfig;
|
||||
import org.apache.kafka.clients.admin.DescribeProducersOptions;
|
||||
import org.apache.kafka.clients.admin.DescribeProducersResult;
|
||||
import org.apache.kafka.clients.admin.ProducerState;
|
||||
import org.apache.kafka.clients.admin.TransactionDescription;
|
||||
import org.apache.kafka.clients.admin.TransactionListing;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
import org.apache.kafka.common.utils.Exit;
|
||||
import org.apache.kafka.common.utils.Time;
|
||||
import org.apache.kafka.common.utils.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static java.util.Collections.singleton;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static net.sourceforge.argparse4j.impl.Arguments.store;
|
||||
|
||||
public abstract class TransactionsCommand {
|
||||
private static final Logger log = LoggerFactory.getLogger(TransactionsCommand.class);
|
||||
|
||||
protected final Time time;
|
||||
|
||||
protected TransactionsCommand(Time time) {
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of this command (e.g. `describe-producers`).
|
||||
*/
|
||||
abstract String name();
|
||||
|
||||
/**
|
||||
* Specify the arguments needed for this command.
|
||||
*/
|
||||
abstract void addSubparser(Subparsers subparsers);
|
||||
|
||||
/**
|
||||
* Execute the command logic.
|
||||
*/
|
||||
abstract void execute(Admin admin, Namespace ns, PrintStream out) throws Exception;
|
||||
|
||||
|
||||
static class AbortTransactionCommand extends TransactionsCommand {
|
||||
|
||||
AbortTransactionCommand(Time time) {
|
||||
super(time);
|
||||
}
|
||||
|
||||
@Override
|
||||
String name() {
|
||||
return "abort";
|
||||
}
|
||||
|
||||
@Override
|
||||
void addSubparser(Subparsers subparsers) {
|
||||
Subparser subparser = subparsers.addParser(name())
|
||||
.help("abort a hanging transaction (requires administrative privileges)");
|
||||
|
||||
subparser.addArgument("--topic")
|
||||
.help("topic name")
|
||||
.action(store())
|
||||
.type(String.class)
|
||||
.required(true);
|
||||
|
||||
subparser.addArgument("--partition")
|
||||
.help("partition number")
|
||||
.action(store())
|
||||
.type(Integer.class)
|
||||
.required(true);
|
||||
|
||||
ArgumentGroup newBrokerArgumentGroup = subparser
|
||||
.addArgumentGroup("Brokers on versions 3.0 and above")
|
||||
.description("For newer brokers, only the start offset of the transaction " +
|
||||
"to be aborted is required");
|
||||
|
||||
newBrokerArgumentGroup.addArgument("--start-offset")
|
||||
.help("start offset of the transaction to abort")
|
||||
.action(store())
|
||||
.type(Long.class);
|
||||
|
||||
ArgumentGroup olderBrokerArgumentGroup = subparser
|
||||
.addArgumentGroup("Brokers on versions older than 3.0")
|
||||
.description("For older brokers, you must provide all of these arguments");
|
||||
|
||||
olderBrokerArgumentGroup.addArgument("--producer-id")
|
||||
.help("producer id")
|
||||
.action(store())
|
||||
.type(Long.class);
|
||||
|
||||
olderBrokerArgumentGroup.addArgument("--producer-epoch")
|
||||
.help("producer epoch")
|
||||
.action(store())
|
||||
.type(Short.class);
|
||||
|
||||
olderBrokerArgumentGroup.addArgument("--coordinator-epoch")
|
||||
.help("coordinator epoch")
|
||||
.action(store())
|
||||
.type(Integer.class);
|
||||
}
|
||||
|
||||
private AbortTransactionSpec buildAbortSpec(
|
||||
Admin admin,
|
||||
TopicPartition topicPartition,
|
||||
long startOffset
|
||||
) throws Exception {
|
||||
final DescribeProducersResult.PartitionProducerState result;
|
||||
try {
|
||||
result = admin.describeProducers(singleton(topicPartition))
|
||||
.partitionResult(topicPartition)
|
||||
.get();
|
||||
} catch (ExecutionException e) {
|
||||
printErrorAndExit("Failed to validate producer state for partition "
|
||||
+ topicPartition, e.getCause());
|
||||
return null;
|
||||
}
|
||||
|
||||
Optional<ProducerState> foundProducerState = result.activeProducers().stream()
|
||||
.filter(producerState -> {
|
||||
OptionalLong txnStartOffsetOpt = producerState.currentTransactionStartOffset();
|
||||
return txnStartOffsetOpt.isPresent() && txnStartOffsetOpt.getAsLong() == startOffset;
|
||||
})
|
||||
.findFirst();
|
||||
|
||||
if (!foundProducerState.isPresent()) {
|
||||
printErrorAndExit("Could not find any open transactions starting at offset " +
|
||||
startOffset + " on partition " + topicPartition);
|
||||
return null;
|
||||
}
|
||||
|
||||
ProducerState producerState = foundProducerState.get();
|
||||
return new AbortTransactionSpec(
|
||||
topicPartition,
|
||||
producerState.producerId(),
|
||||
(short) producerState.producerEpoch(),
|
||||
producerState.coordinatorEpoch().orElse(0)
|
||||
);
|
||||
}
|
||||
|
||||
private void abortTransaction(
|
||||
Admin admin,
|
||||
AbortTransactionSpec abortSpec
|
||||
) throws Exception {
|
||||
try {
|
||||
admin.abortTransaction(abortSpec).all().get();
|
||||
} catch (ExecutionException e) {
|
||||
TransactionsCommand.printErrorAndExit("Failed to abort transaction " + abortSpec, e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
void execute(Admin admin, Namespace ns, PrintStream out) throws Exception {
|
||||
String topicName = ns.getString("topic");
|
||||
Integer partitionId = ns.getInt("partition");
|
||||
TopicPartition topicPartition = new TopicPartition(topicName, partitionId);
|
||||
|
||||
Long startOffset = ns.getLong("start_offset");
|
||||
Long producerId = ns.getLong("producer_id");
|
||||
|
||||
if (startOffset == null && producerId == null) {
|
||||
printErrorAndExit("The transaction to abort must be identified either with " +
|
||||
"--start-offset (for brokers on 3.0 or above) or with " +
|
||||
"--producer-id, --producer-epoch, and --coordinator-epoch (for older brokers)");
|
||||
return;
|
||||
}
|
||||
|
||||
final AbortTransactionSpec abortSpec;
|
||||
if (startOffset == null) {
|
||||
Short producerEpoch = ns.getShort("producer_epoch");
|
||||
if (producerEpoch == null) {
|
||||
printErrorAndExit("Missing required argument --producer-epoch");
|
||||
return;
|
||||
}
|
||||
|
||||
Integer coordinatorEpoch = ns.getInt("coordinator_epoch");
|
||||
if (coordinatorEpoch == null) {
|
||||
printErrorAndExit("Missing required argument --coordinator-epoch");
|
||||
return;
|
||||
}
|
||||
|
||||
// If a transaction was started by a new producerId and became hanging
|
||||
// before the initial commit/abort, then the coordinator epoch will be -1
|
||||
// as seen in the `DescribeProducers` output. In this case, we conservatively
|
||||
// use a coordinator epoch of 0, which is less than or equal to any possible
|
||||
// leader epoch.
|
||||
if (coordinatorEpoch < 0) {
|
||||
coordinatorEpoch = 0;
|
||||
}
|
||||
|
||||
abortSpec = new AbortTransactionSpec(
|
||||
topicPartition,
|
||||
producerId,
|
||||
producerEpoch,
|
||||
coordinatorEpoch
|
||||
);
|
||||
} else {
|
||||
abortSpec = buildAbortSpec(admin, topicPartition, startOffset);
|
||||
}
|
||||
|
||||
abortTransaction(admin, abortSpec);
|
||||
}
|
||||
}
|
||||
|
||||
static class DescribeProducersCommand extends TransactionsCommand {
|
||||
static final String[] HEADERS = new String[]{
|
||||
"ProducerId",
|
||||
"ProducerEpoch",
|
||||
"LatestCoordinatorEpoch",
|
||||
"LastSequence",
|
||||
"LastTimestamp",
|
||||
"CurrentTransactionStartOffset"
|
||||
};
|
||||
|
||||
DescribeProducersCommand(Time time) {
|
||||
super(time);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "describe-producers";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addSubparser(Subparsers subparsers) {
|
||||
Subparser subparser = subparsers.addParser(name())
|
||||
.help("describe the states of active producers for a topic partition");
|
||||
|
||||
subparser.addArgument("--broker-id")
|
||||
.help("optional broker id to describe the producer state on a specific replica")
|
||||
.action(store())
|
||||
.type(Integer.class)
|
||||
.required(false);
|
||||
|
||||
subparser.addArgument("--topic")
|
||||
.help("topic name")
|
||||
.action(store())
|
||||
.type(String.class)
|
||||
.required(true);
|
||||
|
||||
subparser.addArgument("--partition")
|
||||
.help("partition number")
|
||||
.action(store())
|
||||
.type(Integer.class)
|
||||
.required(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Admin admin, Namespace ns, PrintStream out) throws Exception {
|
||||
DescribeProducersOptions options = new DescribeProducersOptions();
|
||||
Optional.ofNullable(ns.getInt("broker_id")).ifPresent(options::brokerId);
|
||||
|
||||
String topicName = ns.getString("topic");
|
||||
Integer partitionId = ns.getInt("partition");
|
||||
TopicPartition topicPartition = new TopicPartition(topicName, partitionId);
|
||||
|
||||
final DescribeProducersResult.PartitionProducerState result;
|
||||
|
||||
try {
|
||||
result = admin.describeProducers(singleton(topicPartition), options)
|
||||
.partitionResult(topicPartition)
|
||||
.get();
|
||||
} catch (ExecutionException e) {
|
||||
String brokerClause = options.brokerId().isPresent() ?
|
||||
"broker " + options.brokerId().getAsInt() :
|
||||
"leader";
|
||||
printErrorAndExit("Failed to describe producers for partition " +
|
||||
topicPartition + " on " + brokerClause, e.getCause());
|
||||
return;
|
||||
}
|
||||
|
||||
List<String[]> rows = result.activeProducers().stream().map(producerState -> {
|
||||
String currentTransactionStartOffsetColumnValue =
|
||||
producerState.currentTransactionStartOffset().isPresent() ?
|
||||
String.valueOf(producerState.currentTransactionStartOffset().getAsLong()) :
|
||||
"None";
|
||||
|
||||
return new String[] {
|
||||
String.valueOf(producerState.producerId()),
|
||||
String.valueOf(producerState.producerEpoch()),
|
||||
String.valueOf(producerState.coordinatorEpoch().orElse(-1)),
|
||||
String.valueOf(producerState.lastSequence()),
|
||||
String.valueOf(producerState.lastTimestamp()),
|
||||
currentTransactionStartOffsetColumnValue
|
||||
};
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
prettyPrintTable(HEADERS, rows, out);
|
||||
}
|
||||
}
|
||||
|
||||
static class DescribeTransactionsCommand extends TransactionsCommand {
|
||||
static final String[] HEADERS = new String[]{
|
||||
"CoordinatorId",
|
||||
"TransactionalId",
|
||||
"ProducerId",
|
||||
"ProducerEpoch",
|
||||
"TransactionState",
|
||||
"TransactionTimeoutMs",
|
||||
"CurrentTransactionStartTimeMs",
|
||||
"TransactionDurationMs",
|
||||
"TopicPartitions"
|
||||
};
|
||||
|
||||
DescribeTransactionsCommand(Time time) {
|
||||
super(time);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "describe";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addSubparser(Subparsers subparsers) {
|
||||
Subparser subparser = subparsers.addParser(name())
|
||||
.description("Describe the state of an active transactional-id.")
|
||||
.help("describe the state of an active transactional-id");
|
||||
|
||||
subparser.addArgument("--transactional-id")
|
||||
.help("transactional id")
|
||||
.action(store())
|
||||
.type(String.class)
|
||||
.required(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Admin admin, Namespace ns, PrintStream out) throws Exception {
|
||||
String transactionalId = ns.getString("transactional_id");
|
||||
|
||||
final TransactionDescription result;
|
||||
try {
|
||||
result = admin.describeTransactions(singleton(transactionalId))
|
||||
.description(transactionalId)
|
||||
.get();
|
||||
} catch (ExecutionException e) {
|
||||
printErrorAndExit("Failed to describe transaction state of " +
|
||||
"transactional-id `" + transactionalId + "`", e.getCause());
|
||||
return;
|
||||
}
|
||||
|
||||
final String transactionDurationMsColumnValue;
|
||||
final String transactionStartTimeMsColumnValue;
|
||||
|
||||
if (result.transactionStartTimeMs().isPresent()) {
|
||||
long transactionStartTimeMs = result.transactionStartTimeMs().getAsLong();
|
||||
transactionStartTimeMsColumnValue = String.valueOf(transactionStartTimeMs);
|
||||
transactionDurationMsColumnValue = String.valueOf(time.milliseconds() - transactionStartTimeMs);
|
||||
} else {
|
||||
transactionStartTimeMsColumnValue = "None";
|
||||
transactionDurationMsColumnValue = "None";
|
||||
}
|
||||
|
||||
String[] row = new String[]{
|
||||
String.valueOf(result.coordinatorId()),
|
||||
transactionalId,
|
||||
String.valueOf(result.producerId()),
|
||||
String.valueOf(result.producerEpoch()),
|
||||
result.state().toString(),
|
||||
String.valueOf(result.transactionTimeoutMs()),
|
||||
transactionStartTimeMsColumnValue,
|
||||
transactionDurationMsColumnValue,
|
||||
Utils.join(result.topicPartitions(), ",")
|
||||
};
|
||||
|
||||
prettyPrintTable(HEADERS, singletonList(row), out);
|
||||
}
|
||||
}
|
||||
|
||||
static class ListTransactionsCommand extends TransactionsCommand {
|
||||
static final String[] HEADERS = new String[] {
|
||||
"TransactionalId",
|
||||
"Coordinator",
|
||||
"ProducerId",
|
||||
"TransactionState"
|
||||
};
|
||||
|
||||
ListTransactionsCommand(Time time) {
|
||||
super(time);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "list";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addSubparser(Subparsers subparsers) {
|
||||
subparsers.addParser(name())
|
||||
.help("list transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Admin admin, Namespace ns, PrintStream out) throws Exception {
|
||||
final Map<Integer, Collection<TransactionListing>> result;
|
||||
|
||||
try {
|
||||
result = admin.listTransactions()
|
||||
.allByBrokerId()
|
||||
.get();
|
||||
} catch (ExecutionException e) {
|
||||
printErrorAndExit("Failed to list transactions", e.getCause());
|
||||
return;
|
||||
}
|
||||
|
||||
List<String[]> rows = new ArrayList<>();
|
||||
for (Map.Entry<Integer, Collection<TransactionListing>> brokerListingsEntry : result.entrySet()) {
|
||||
String coordinatorIdString = brokerListingsEntry.getKey().toString();
|
||||
Collection<TransactionListing> listings = brokerListingsEntry.getValue();
|
||||
|
||||
for (TransactionListing listing : listings) {
|
||||
rows.add(new String[] {
|
||||
listing.transactionalId(),
|
||||
coordinatorIdString,
|
||||
String.valueOf(listing.producerId()),
|
||||
listing.state().toString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prettyPrintTable(HEADERS, rows, out);
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendColumnValue(
|
||||
StringBuilder rowBuilder,
|
||||
String value,
|
||||
int length
|
||||
) {
|
||||
int padLength = length - value.length();
|
||||
rowBuilder.append(value);
|
||||
for (int i = 0; i < padLength; i++)
|
||||
rowBuilder.append(' ');
|
||||
}
|
||||
|
||||
private static void printRow(
|
||||
List<Integer> columnLengths,
|
||||
String[] row,
|
||||
PrintStream out
|
||||
) {
|
||||
StringBuilder rowBuilder = new StringBuilder();
|
||||
for (int i = 0; i < row.length; i++) {
|
||||
Integer columnLength = columnLengths.get(i);
|
||||
String columnValue = row[i];
|
||||
appendColumnValue(rowBuilder, columnValue, columnLength);
|
||||
rowBuilder.append('\t');
|
||||
}
|
||||
out.println(rowBuilder);
|
||||
}
|
||||
|
||||
private static void prettyPrintTable(
|
||||
String[] headers,
|
||||
List<String[]> rows,
|
||||
PrintStream out
|
||||
) {
|
||||
List<Integer> columnLengths = Arrays.stream(headers)
|
||||
.map(String::length)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (String[] row : rows) {
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
columnLengths.set(i, Math.max(columnLengths.get(i), row[i].length()));
|
||||
}
|
||||
}
|
||||
|
||||
printRow(columnLengths, headers, out);
|
||||
rows.forEach(row -> printRow(columnLengths, row, out));
|
||||
}
|
||||
|
||||
private static void printErrorAndExit(String message, Throwable t) {
|
||||
log.debug(message, t);
|
||||
|
||||
String exitMessage = message + ": " + t.getMessage() + "." +
|
||||
" Enable debug logging for additional detail.";
|
||||
|
||||
printErrorAndExit(exitMessage);
|
||||
}
|
||||
|
||||
private static void printErrorAndExit(String message) {
|
||||
System.err.println(message);
|
||||
Exit.exit(1, message);
|
||||
}
|
||||
|
||||
private static Admin buildAdminClient(Namespace ns) {
|
||||
final Properties properties;
|
||||
|
||||
String configFile = ns.getString("command_config");
|
||||
if (configFile == null) {
|
||||
properties = new Properties();
|
||||
} else {
|
||||
try {
|
||||
properties = Utils.loadProps(configFile);
|
||||
} catch (IOException e) {
|
||||
printErrorAndExit("Failed to load admin client properties", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String bootstrapServers = ns.getString("bootstrap_server");
|
||||
properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
|
||||
return Admin.create(properties);
|
||||
}
|
||||
|
||||
static ArgumentParser buildBaseParser() {
|
||||
ArgumentParser parser = ArgumentParsers
|
||||
.newArgumentParser("kafka-transactions.sh");
|
||||
|
||||
parser.description("This tool is used to analyze the transactional state of producers in the cluster. " +
|
||||
"It can be used to detect and recover from hanging transactions.");
|
||||
|
||||
parser.addArgument("-v", "--version")
|
||||
.action(new PrintVersionAndExitAction())
|
||||
.help("show the version of this Kafka distribution and exit");
|
||||
|
||||
parser.addArgument("--command-config")
|
||||
.help("property file containing configs to be passed to admin client")
|
||||
.action(store())
|
||||
.type(String.class)
|
||||
.metavar("FILE")
|
||||
.required(false);
|
||||
|
||||
parser.addArgument("--bootstrap-server")
|
||||
.help("hostname and port for the broker to connect to, in the form `host:port` " +
|
||||
"(multiple comma-separated entries can be given)")
|
||||
.action(store())
|
||||
.type(String.class)
|
||||
.metavar("host:port")
|
||||
.required(true);
|
||||
|
||||
return parser;
|
||||
}
|
||||
|
||||
static void execute(
|
||||
String[] args,
|
||||
Function<Namespace, Admin> adminSupplier,
|
||||
PrintStream out,
|
||||
Time time
|
||||
) throws Exception {
|
||||
List<TransactionsCommand> commands = Arrays.asList(
|
||||
new ListTransactionsCommand(time),
|
||||
new DescribeTransactionsCommand(time),
|
||||
new DescribeProducersCommand(time),
|
||||
new AbortTransactionCommand(time)
|
||||
);
|
||||
|
||||
ArgumentParser parser = buildBaseParser();
|
||||
Subparsers subparsers = parser.addSubparsers()
|
||||
.dest("command")
|
||||
.title("commands")
|
||||
.metavar("COMMAND");
|
||||
commands.forEach(command -> command.addSubparser(subparsers));
|
||||
|
||||
final Namespace ns;
|
||||
|
||||
try {
|
||||
ns = parser.parseArgs(args);
|
||||
} catch (ArgumentParserException e) {
|
||||
parser.handleError(e);
|
||||
Exit.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
Admin admin = adminSupplier.apply(ns);
|
||||
String commandName = ns.getString("command");
|
||||
|
||||
Optional<TransactionsCommand> commandOpt = commands.stream()
|
||||
.filter(cmd -> cmd.name().equals(commandName))
|
||||
.findFirst();
|
||||
|
||||
if (!commandOpt.isPresent()) {
|
||||
printErrorAndExit("Unexpected command " + commandName);
|
||||
}
|
||||
|
||||
TransactionsCommand command = commandOpt.get();
|
||||
command.execute(admin, ns, out);
|
||||
Exit.exit(0);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
execute(args, TransactionsCommand::buildAdminClient, System.out, Time.SYSTEM);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,492 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.kafka.tools;
|
||||
|
||||
import org.apache.kafka.clients.admin.AbortTransactionResult;
|
||||
import org.apache.kafka.clients.admin.AbortTransactionSpec;
|
||||
import org.apache.kafka.clients.admin.Admin;
|
||||
import org.apache.kafka.clients.admin.DescribeProducersOptions;
|
||||
import org.apache.kafka.clients.admin.DescribeProducersResult;
|
||||
import org.apache.kafka.clients.admin.DescribeProducersResult.PartitionProducerState;
|
||||
import org.apache.kafka.clients.admin.DescribeTransactionsResult;
|
||||
import org.apache.kafka.clients.admin.ListTransactionsResult;
|
||||
import org.apache.kafka.clients.admin.ProducerState;
|
||||
import org.apache.kafka.clients.admin.TransactionDescription;
|
||||
import org.apache.kafka.clients.admin.TransactionListing;
|
||||
import org.apache.kafka.clients.admin.TransactionState;
|
||||
import org.apache.kafka.common.KafkaFuture;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
import org.apache.kafka.common.internals.KafkaFutureImpl;
|
||||
import org.apache.kafka.common.utils.Exit;
|
||||
import org.apache.kafka.common.utils.MockTime;
|
||||
import org.apache.kafka.common.utils.Utils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.singleton;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
public class TransactionsCommandTest {
|
||||
|
||||
private final MockExitProcedure exitProcedure = new MockExitProcedure();
|
||||
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
private final PrintStream out = new PrintStream(outputStream);
|
||||
private final MockTime time = new MockTime();
|
||||
private final Admin admin = Mockito.mock(Admin.class);
|
||||
|
||||
@BeforeEach
|
||||
public void setupExitProcedure() {
|
||||
Exit.setExitProcedure(exitProcedure);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void resetExitProcedure() {
|
||||
Exit.resetExitProcedure();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeProducersTopicRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe-producers",
|
||||
"--partition",
|
||||
"0"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeProducersPartitionRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe-producers",
|
||||
"--topic",
|
||||
"foo"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeProducersLeader() throws Exception {
|
||||
TopicPartition topicPartition = new TopicPartition("foo", 5);
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe-producers",
|
||||
"--topic",
|
||||
topicPartition.topic(),
|
||||
"--partition",
|
||||
String.valueOf(topicPartition.partition())
|
||||
};
|
||||
|
||||
testDescribeProducers(topicPartition, args, new DescribeProducersOptions());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeProducersSpecificReplica() throws Exception {
|
||||
TopicPartition topicPartition = new TopicPartition("foo", 5);
|
||||
int brokerId = 5;
|
||||
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe-producers",
|
||||
"--topic",
|
||||
topicPartition.topic(),
|
||||
"--partition",
|
||||
String.valueOf(topicPartition.partition()),
|
||||
"--broker-id",
|
||||
String.valueOf(brokerId)
|
||||
};
|
||||
|
||||
testDescribeProducers(topicPartition, args, new DescribeProducersOptions().brokerId(brokerId));
|
||||
}
|
||||
|
||||
private void testDescribeProducers(
|
||||
TopicPartition topicPartition,
|
||||
String[] args,
|
||||
DescribeProducersOptions expectedOptions
|
||||
) throws Exception {
|
||||
DescribeProducersResult describeResult = Mockito.mock(DescribeProducersResult.class);
|
||||
KafkaFuture<PartitionProducerState> describeFuture = KafkaFutureImpl.completedFuture(
|
||||
new PartitionProducerState(asList(
|
||||
new ProducerState(12345L, 15, 1300, 1599509565L,
|
||||
OptionalInt.of(20), OptionalLong.of(990)),
|
||||
new ProducerState(98765L, 30, 2300, 1599509599L,
|
||||
OptionalInt.empty(), OptionalLong.empty())
|
||||
)));
|
||||
|
||||
|
||||
Mockito.when(describeResult.partitionResult(topicPartition)).thenReturn(describeFuture);
|
||||
Mockito.when(admin.describeProducers(singleton(topicPartition), expectedOptions)).thenReturn(describeResult);
|
||||
|
||||
execute(args);
|
||||
assertNormalExit();
|
||||
|
||||
List<List<String>> table = readOutputAsTable();
|
||||
assertEquals(3, table.size());
|
||||
|
||||
List<String> expectedHeaders = asList(TransactionsCommand.DescribeProducersCommand.HEADERS);
|
||||
assertEquals(expectedHeaders, table.get(0));
|
||||
|
||||
Set<List<String>> expectedRows = Utils.mkSet(
|
||||
asList("12345", "15", "20", "1300", "1599509565", "990"),
|
||||
asList("98765", "30", "-1", "2300", "1599509599", "None")
|
||||
);
|
||||
assertEquals(expectedRows, new HashSet<>(table.subList(1, table.size())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testListTransactions() throws Exception {
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"list"
|
||||
};
|
||||
|
||||
ListTransactionsResult listResult = Mockito.mock(ListTransactionsResult.class);
|
||||
|
||||
Map<Integer, Collection<TransactionListing>> transactions = new HashMap<>();
|
||||
transactions.put(0, asList(
|
||||
new TransactionListing("foo", 12345L, TransactionState.ONGOING),
|
||||
new TransactionListing("bar", 98765L, TransactionState.PREPARE_ABORT)
|
||||
));
|
||||
transactions.put(1, singletonList(
|
||||
new TransactionListing("baz", 13579L, TransactionState.COMPLETE_COMMIT)
|
||||
));
|
||||
|
||||
KafkaFuture<Map<Integer, Collection<TransactionListing>>> listTransactionsFuture =
|
||||
KafkaFutureImpl.completedFuture(transactions);
|
||||
|
||||
Mockito.when(admin.listTransactions()).thenReturn(listResult);
|
||||
Mockito.when(listResult.allByBrokerId()).thenReturn(listTransactionsFuture);
|
||||
|
||||
execute(args);
|
||||
assertNormalExit();
|
||||
|
||||
List<List<String>> table = readOutputAsTable();
|
||||
assertEquals(4, table.size());
|
||||
|
||||
// Assert expected headers
|
||||
List<String> expectedHeaders = asList(TransactionsCommand.ListTransactionsCommand.HEADERS);
|
||||
assertEquals(expectedHeaders, table.get(0));
|
||||
|
||||
Set<List<String>> expectedRows = Utils.mkSet(
|
||||
asList("foo", "0", "12345", "Ongoing"),
|
||||
asList("bar", "0", "98765", "PrepareAbort"),
|
||||
asList("baz", "1", "13579", "CompleteCommit")
|
||||
);
|
||||
assertEquals(expectedRows, new HashSet<>(table.subList(1, table.size())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsTransactionalIdRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransaction() throws Exception {
|
||||
String transactionalId = "foo";
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"describe",
|
||||
"--transactional-id",
|
||||
transactionalId
|
||||
};
|
||||
|
||||
DescribeTransactionsResult describeResult = Mockito.mock(DescribeTransactionsResult.class);
|
||||
|
||||
int coordinatorId = 5;
|
||||
long transactionStartTime = time.milliseconds();
|
||||
|
||||
KafkaFuture<TransactionDescription> describeFuture = KafkaFutureImpl.completedFuture(
|
||||
new TransactionDescription(
|
||||
coordinatorId,
|
||||
TransactionState.ONGOING,
|
||||
12345L,
|
||||
15,
|
||||
10000,
|
||||
OptionalLong.of(transactionStartTime),
|
||||
singleton(new TopicPartition("bar", 0))
|
||||
));
|
||||
|
||||
Mockito.when(describeResult.description(transactionalId)).thenReturn(describeFuture);
|
||||
Mockito.when(admin.describeTransactions(singleton(transactionalId))).thenReturn(describeResult);
|
||||
|
||||
// Add a little time so that we can see a positive transaction duration in the output
|
||||
time.sleep(5000);
|
||||
|
||||
execute(args);
|
||||
assertNormalExit();
|
||||
|
||||
List<List<String>> table = readOutputAsTable();
|
||||
assertEquals(2, table.size());
|
||||
|
||||
List<String> expectedHeaders = asList(TransactionsCommand.DescribeTransactionsCommand.HEADERS);
|
||||
assertEquals(expectedHeaders, table.get(0));
|
||||
|
||||
List<String> expectedRow = asList(
|
||||
String.valueOf(coordinatorId),
|
||||
transactionalId,
|
||||
"12345",
|
||||
"15",
|
||||
"Ongoing",
|
||||
"10000",
|
||||
String.valueOf(transactionStartTime),
|
||||
"5000",
|
||||
"bar-0"
|
||||
);
|
||||
assertEquals(expectedRow, table.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsStartOffsetOrProducerIdRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
"foo",
|
||||
"--partition",
|
||||
"0"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsTopicRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--partition",
|
||||
"0",
|
||||
"--start-offset",
|
||||
"9990"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsPartitionRequired() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
"foo",
|
||||
"--start-offset",
|
||||
"9990"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsProducerEpochRequiredWithProducerId() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
"foo",
|
||||
"--partition",
|
||||
"0",
|
||||
"--producer-id",
|
||||
"12345"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDescribeTransactionsCoordinatorEpochRequiredWithProducerId() throws Exception {
|
||||
assertCommandFailure(new String[]{
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
"foo",
|
||||
"--partition",
|
||||
"0",
|
||||
"--producer-id",
|
||||
"12345",
|
||||
"--producer-epoch",
|
||||
"15"
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNewBrokerAbortTransaction() throws Exception {
|
||||
TopicPartition topicPartition = new TopicPartition("foo", 5);
|
||||
long startOffset = 9173;
|
||||
long producerId = 12345L;
|
||||
short producerEpoch = 15;
|
||||
int coordinatorEpoch = 76;
|
||||
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
topicPartition.topic(),
|
||||
"--partition",
|
||||
String.valueOf(topicPartition.partition()),
|
||||
"--start-offset",
|
||||
String.valueOf(startOffset)
|
||||
};
|
||||
|
||||
DescribeProducersResult describeResult = Mockito.mock(DescribeProducersResult.class);
|
||||
KafkaFuture<PartitionProducerState> describeFuture = KafkaFutureImpl.completedFuture(
|
||||
new PartitionProducerState(singletonList(
|
||||
new ProducerState(producerId, producerEpoch, 1300, 1599509565L,
|
||||
OptionalInt.of(coordinatorEpoch), OptionalLong.of(startOffset))
|
||||
)));
|
||||
|
||||
AbortTransactionResult abortTransactionResult = Mockito.mock(AbortTransactionResult.class);
|
||||
KafkaFuture<Void> abortFuture = KafkaFutureImpl.completedFuture(null);
|
||||
AbortTransactionSpec expectedAbortSpec = new AbortTransactionSpec(
|
||||
topicPartition, producerId, producerEpoch, coordinatorEpoch);
|
||||
|
||||
Mockito.when(describeResult.partitionResult(topicPartition)).thenReturn(describeFuture);
|
||||
Mockito.when(admin.describeProducers(singleton(topicPartition))).thenReturn(describeResult);
|
||||
|
||||
Mockito.when(abortTransactionResult.all()).thenReturn(abortFuture);
|
||||
Mockito.when(admin.abortTransaction(expectedAbortSpec)).thenReturn(abortTransactionResult);
|
||||
|
||||
execute(args);
|
||||
assertNormalExit();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {29, -1})
|
||||
public void testOldBrokerAbortTransactionWithUnknownCoordinatorEpoch(int coordinatorEpoch) throws Exception {
|
||||
TopicPartition topicPartition = new TopicPartition("foo", 5);
|
||||
long producerId = 12345L;
|
||||
short producerEpoch = 15;
|
||||
|
||||
String[] args = new String[] {
|
||||
"--bootstrap-server",
|
||||
"localhost:9092",
|
||||
"abort",
|
||||
"--topic",
|
||||
topicPartition.topic(),
|
||||
"--partition",
|
||||
String.valueOf(topicPartition.partition()),
|
||||
"--producer-id",
|
||||
String.valueOf(producerId),
|
||||
"--producer-epoch",
|
||||
String.valueOf(producerEpoch),
|
||||
"--coordinator-epoch",
|
||||
String.valueOf(coordinatorEpoch)
|
||||
};
|
||||
|
||||
AbortTransactionResult abortTransactionResult = Mockito.mock(AbortTransactionResult.class);
|
||||
KafkaFuture<Void> abortFuture = KafkaFutureImpl.completedFuture(null);
|
||||
|
||||
final int expectedCoordinatorEpoch;
|
||||
if (coordinatorEpoch < 0) {
|
||||
expectedCoordinatorEpoch = 0;
|
||||
} else {
|
||||
expectedCoordinatorEpoch = coordinatorEpoch;
|
||||
}
|
||||
|
||||
AbortTransactionSpec expectedAbortSpec = new AbortTransactionSpec(
|
||||
topicPartition, producerId, producerEpoch, expectedCoordinatorEpoch);
|
||||
|
||||
Mockito.when(abortTransactionResult.all()).thenReturn(abortFuture);
|
||||
Mockito.when(admin.abortTransaction(expectedAbortSpec)).thenReturn(abortTransactionResult);
|
||||
|
||||
execute(args);
|
||||
assertNormalExit();
|
||||
}
|
||||
|
||||
private void execute(String[] args) throws Exception {
|
||||
TransactionsCommand.execute(args, ns -> admin, out, time);
|
||||
}
|
||||
|
||||
private List<List<String>> readOutputAsTable() throws IOException {
|
||||
List<List<String>> table = new ArrayList<>();
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
|
||||
|
||||
while (true) {
|
||||
List<String> row = readRow(reader);
|
||||
if (row == null) {
|
||||
break;
|
||||
}
|
||||
table.add(row);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private List<String> readRow(BufferedReader reader) throws IOException {
|
||||
String line = reader.readLine();
|
||||
if (line == null) {
|
||||
return null;
|
||||
} else {
|
||||
return asList(line.split("\\s+"));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNormalExit() {
|
||||
assertTrue(exitProcedure.hasExited);
|
||||
assertEquals(0, exitProcedure.statusCode);
|
||||
}
|
||||
|
||||
private void assertCommandFailure(String[] args) throws Exception {
|
||||
execute(args);
|
||||
assertTrue(exitProcedure.hasExited);
|
||||
assertEquals(1, exitProcedure.statusCode);
|
||||
}
|
||||
|
||||
private static class MockExitProcedure implements Exit.Procedure {
|
||||
private boolean hasExited = false;
|
||||
private int statusCode;
|
||||
|
||||
@Override
|
||||
public void execute(int statusCode, String message) {
|
||||
if (!this.hasExited) {
|
||||
this.hasExited = true;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue