Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@
import org.apache.kafka.common.requests.FindCoordinatorResponse;
import org.apache.kafka.common.requests.IncrementalAlterConfigsRequest;
import org.apache.kafka.common.requests.IncrementalAlterConfigsResponse;
import org.apache.kafka.common.requests.JoinGroupRequest;
import org.apache.kafka.common.requests.LeaveGroupRequest;
import org.apache.kafka.common.requests.LeaveGroupResponse;
import org.apache.kafka.common.requests.ListGroupsRequest;
Expand Down Expand Up @@ -3612,6 +3611,25 @@ private boolean dependsOnSpecificNode(ConfigResource resource) {
|| resource.type() == ConfigResource.Type.BROKER_LOGGER;
}

private List<MemberIdentity> getMembersFromGroup(String groupId) {
Collection<MemberDescription> members;
try {
members = describeConsumerGroups(Collections.singleton(groupId)).describedGroups().get(groupId).get().members();
} catch (Exception ex) {
throw new KafkaException("Encounter exception when trying to get members from group: " + groupId, ex);
}

List<MemberIdentity> membersToRemove = new ArrayList<>();
for (final MemberDescription member : members) {
if (member.groupInstanceId().isPresent()) {
membersToRemove.add(new MemberIdentity().setGroupInstanceId(member.groupInstanceId().get()));
} else {
membersToRemove.add(new MemberIdentity().setMemberId(member.consumerId()));
}
}
return membersToRemove;
}

@Override
public RemoveMembersFromConsumerGroupResult removeMembersFromConsumerGroup(String groupId,
RemoveMembersFromConsumerGroupOptions options) {
Expand All @@ -3623,22 +3641,24 @@ public RemoveMembersFromConsumerGroupResult removeMembersFromConsumerGroup(Strin
ConsumerGroupOperationContext<Map<MemberIdentity, Errors>, RemoveMembersFromConsumerGroupOptions> context =
new ConsumerGroupOperationContext<>(groupId, options, deadline, future);

Call findCoordinatorCall = getFindCoordinatorCall(context,
() -> getRemoveMembersFromGroupCall(context));
List<MemberIdentity> members;
if (options.removeAll()) {
members = getMembersFromGroup(groupId);
} else {
members = options.members().stream().map(MemberToRemove::toMemberIdentity).collect(Collectors.toList());
}
Call findCoordinatorCall = getFindCoordinatorCall(context, () -> getRemoveMembersFromGroupCall(context, members));
runnable.call(findCoordinatorCall, startFindCoordinatorMs);

return new RemoveMembersFromConsumerGroupResult(future, options.members());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If option.members() is empty, it implies that we do a removeAll() -- hence, should we pass in members into the RemoveMembersFromConsumerGroupResult instead of options.members() ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--- If option.members() is empty, it implies that we do a removeAll()
=> Yes, that is correct.

--- hence, should we pass in members into the RemoveMembersFromConsumerGroupResult instead of options.members()
=> The members is of type List<MemberIdentity> and MemberIdentity contains field: memberId which supports the removal of dynamic members, while options.members() is of type: Set<MemberToRemove>, MemberToRemove only supports static member removal specification, in RemoveMembersFromConsumerGroupResult we treat similarly like in RemoveMembersFromConsumerGroupOptions, empty members implies removeAll,
we handle it in this way because we think in non removeAll scenario we would only remove static members, while in removeAll scenario we may remove both static and dynamic members.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying.

}

private Call getRemoveMembersFromGroupCall(ConsumerGroupOperationContext<Map<MemberIdentity, Errors>, RemoveMembersFromConsumerGroupOptions> context) {
return new Call("leaveGroup",
context.deadline(),
new ConstantNodeIdProvider(context.node().get().id())) {
private Call getRemoveMembersFromGroupCall(ConsumerGroupOperationContext<Map<MemberIdentity, Errors>, RemoveMembersFromConsumerGroupOptions> context,
List<MemberIdentity> members) {
return new Call("leaveGroup", context.deadline(), new ConstantNodeIdProvider(context.node().get().id())) {
@Override
LeaveGroupRequest.Builder createRequest(int timeoutMs) {
return new LeaveGroupRequest.Builder(context.groupId(),
context.options().members().stream().map(
MemberToRemove::toMemberIdentity).collect(Collectors.toList()));
return new LeaveGroupRequest.Builder(context.groupId(), members);
}

@Override
Expand All @@ -3647,7 +3667,7 @@ void handleResponse(AbstractResponse abstractResponse) {

// If coordinator changed since we fetched it, retry
if (ConsumerGroupOperationContext.hasCoordinatorMoved(response)) {
Call call = getRemoveMembersFromGroupCall(context);
Call call = getRemoveMembersFromGroupCall(context, members);
rescheduleFindCoordinatorTask(context, () -> call, this);
return;
}
Expand All @@ -3657,10 +3677,8 @@ void handleResponse(AbstractResponse abstractResponse) {

final Map<MemberIdentity, Errors> memberErrors = new HashMap<>();
for (MemberResponse memberResponse : response.memberResponses()) {
// We set member.id to empty here explicitly, so that the lookup will succeed as user doesn't
// know the exact member.id.
memberErrors.put(new MemberIdentity()
.setMemberId(JoinGroupRequest.UNKNOWN_MEMBER_ID)
.setMemberId(memberResponse.memberId())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see this doesn't hold true for a plain static member removal. Let's discuss why skipping the individual member check in RemoveMembersFromConsumerGroupResult makes sense over there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For removing static members, this still true because we put memberId as "" in the request, and the server will also response with the same request field. (Verified GroupCoordinator#handleLeaveGroup)
For removing dynamic members, we need this change to know the memberId for the caller.
I suppose the individual check here is just to check the response against the members to be removed(for removeAll scenario)? Previously I thought of putting all members got from KafkaAdminClient#getMembersFromGroup in the RemoveMembersFromConsumerGroupResult for checking, but in removeAll scenario, we get members as MemberIdentity which cannot be converted back to MemberToRemove, so I'm hesitate to do in this way

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I understand the change. Also not sure if I can follow the comments. Can you elaborate?

.setGroupInstanceId(memberResponse.groupInstanceId()),
Errors.forCode(memberResponse.errorCode()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.apache.kafka.common.annotation.InterfaceStability;

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

Expand All @@ -34,10 +35,21 @@ public class RemoveMembersFromConsumerGroupOptions extends AbstractOptions<Remov
private Set<MemberToRemove> members;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just make members to be Optional<Set<MemberToRemove>> so that we don't need a separate removeAll parameter?

@feyman2016 feyman2016 May 6, 2020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Taking a step further, can we just keep the the type Set<MemberToRemove> for members unchanged and treat it as removeAll if the members is empty set?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated~


public RemoveMembersFromConsumerGroupOptions(Collection<MemberToRemove> members) {
if (members.isEmpty()) {
throw new IllegalArgumentException("Invalid empty members has been provided");
}
this.members = new HashSet<>(members);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we have different semantics for an empty collection (it was "remove nothing" originally, and we change it to "remove all"), I am wondering if we should do a check if members is empty or not and throw an exception if empty? Or at least log a WARNING that empty implies "remove all" now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense. It will throw exception if empty members provided now.

}

public RemoveMembersFromConsumerGroupOptions() {
this.members = Collections.emptySet();
}

public Set<MemberToRemove> members() {
return members;
}

public boolean removeAll() {
return members.isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/
package org.apache.kafka.clients.admin;

import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.common.message.LeaveGroupRequestData.MemberIdentity;
Expand Down Expand Up @@ -51,9 +52,21 @@ public KafkaFuture<Void> all() {
if (throwable != null) {
result.completeExceptionally(throwable);
} else {
for (MemberToRemove memberToRemove : memberInfos) {
if (maybeCompleteExceptionally(memberErrors, memberToRemove.toMemberIdentity(), result)) {
return;
if (removeAll()) {

@mjsax mjsax May 27, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why the removeAll() case needs to be handled differently? Can you elaborate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because in non removeAll scenario, we have put the members to be deleted in the RemoveMembersFromConsumerGroupResult#memberInfos, while in the removeAll scenario, we don't do so(members to be deleted are decided in the private method: KafkaAdminClient#getMembersFromGroup of KafkaAdminClient).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, while memberInfo is empty for the removeAll case, I am still wondering if the code for removeAll would not work for the other case, too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the question, could you elaborate more?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just do for both cases?

for (Map.Entry<MemberIdentity, Errors> entry: memberErrors.entrySet()) {
    Exception exception = entry.getValue().exception();
    if (exception != null) {
        Throwable ex = new KafkaException("Encounter exception when trying to remove: "
             + entry.getKey(), exception);
        result.completeExceptionally(ex);
        return;
    }
}

The "issue" with using memberInfos is, that for the removeAll() case it's empty and we cannot use it. However, memberErrors should have an entry for all members for both cases?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid not because, in the non removeAll scenario, caller specify the members(memberInfos) to be deleted, and according to maybeCompleteExceptionally, the memberInfos is used because it might sometimes happen that certain member in memberInfos cannot be found in memberErrors , that's the reason I didn't use the removeAll logic for all cases.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for explaining!

for (Map.Entry<MemberIdentity, Errors> entry: memberErrors.entrySet()) {
Exception exception = entry.getValue().exception();
if (exception != null) {
Throwable ex = new KafkaException("Encounter exception when trying to remove: "

@feyman2016 feyman2016 May 22, 2020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap to let the failed member info available for caller like StreamsResetter. Only capture the first found member error like in the non removeAll scenario.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put the exception in the cause so that we could verify the cause in KafkaAdminClientTest, as:

if (exception != null) {
  result.completeExceptionally(new KafkaException(
 "Encounter exception when trying to remove: " + entry.getKey(), exception));
  return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, updated

+ entry.getKey(), exception);
result.completeExceptionally(ex);
return;
}
}
} else {
for (MemberToRemove memberToRemove : memberInfos) {
if (maybeCompleteExceptionally(memberErrors, memberToRemove.toMemberIdentity(), result)) {
return;
}
}
}
result.complete(null);
Expand All @@ -66,6 +79,9 @@ public KafkaFuture<Void> all() {
* Returns the selected member future.
*/
public KafkaFuture<Void> memberResult(MemberToRemove member) {
if (removeAll()) {
throw new IllegalArgumentException("The method: memberResult is not applicable in 'removeAll' mode");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why that? I understand that we expect that users don't know the memberId if the so a "remove all"; however, I don't see why we need to disallow this call? Can you elaborate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since in the removeAll scenario, we don't save the members to be deleted in RemoveMembersFromConsumerGroupResult, so I think calling memberResult doesn't seem applicative.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Makes sense.

}
if (!memberInfos.contains(member)) {
throw new IllegalArgumentException("Member " + member + " was not included in the original request");
}
Expand Down Expand Up @@ -93,4 +109,8 @@ private boolean maybeCompleteExceptionally(Map<MemberIdentity, Errors> memberErr
return false;
}
}

private boolean removeAll() {
return memberInfos.isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
import org.apache.kafka.common.requests.ElectLeadersResponse;
import org.apache.kafka.common.requests.FindCoordinatorResponse;
import org.apache.kafka.common.requests.IncrementalAlterConfigsResponse;
import org.apache.kafka.common.requests.JoinGroupRequest;
import org.apache.kafka.common.requests.LeaveGroupResponse;
import org.apache.kafka.common.requests.ListGroupsResponse;
import org.apache.kafka.common.requests.ListOffsetResponse;
Expand Down Expand Up @@ -379,6 +380,23 @@ private static MetadataResponse prepareMetadataResponse(Cluster cluster, Errors
MetadataResponse.AUTHORIZED_OPERATIONS_OMITTED);
}

private static DescribeGroupsResponseData prepareDescribeGroupsResponseData(String groupId,
List<String> groupInstances,
List<TopicPartition> topicPartitions) {
final ByteBuffer memberAssignment = ConsumerProtocol.serializeAssignment(new ConsumerPartitionAssignor.Assignment(topicPartitions));
List<DescribedGroupMember> describedGroupMembers = groupInstances.stream().map(groupInstance -> DescribeGroupsResponse.groupMember(JoinGroupRequest.UNKNOWN_MEMBER_ID,
groupInstance, "clientId0", "clientHost", new byte[memberAssignment.remaining()], null)).collect(Collectors.toList());
DescribeGroupsResponseData data = new DescribeGroupsResponseData();
data.groups().add(DescribeGroupsResponse.groupMetadata(
groupId,
Errors.NONE,
"",
ConsumerProtocol.PROTOCOL_TYPE,
"",
describedGroupMembers,
Collections.emptySet()));
return data;
}
/**
* Test that the client properly times out when we don't receive any metadata.
*/
Expand Down Expand Up @@ -2411,6 +2429,50 @@ public void testRemoveMembersFromGroup() throws Exception {
assertNull(noErrorResult.all().get());
assertNull(noErrorResult.memberResult(memberOne).get());
assertNull(noErrorResult.memberResult(memberTwo).get());

// Test the "removeAll" scenario
final List<TopicPartition> topicPartitions = Arrays.asList(1, 2, 3).stream().map(partition -> new TopicPartition("my_topic", partition))
.collect(Collectors.toList());
// construct the DescribeGroupsResponse
DescribeGroupsResponseData data = prepareDescribeGroupsResponseData(groupId, Arrays.asList(instanceOne, instanceTwo), topicPartitions);

// Return with partial failure for "removeAll" scenario
// 1 prepare response for AdminClient.describeConsumerGroups
env.kafkaClient().prepareResponse(prepareFindCoordinatorResponse(Errors.NONE, env.cluster().controller()));
env.kafkaClient().prepareResponse(new DescribeGroupsResponse(data));

// 2 KafkaAdminClient encounter partial failure when trying to delete all members
env.kafkaClient().prepareResponse(prepareFindCoordinatorResponse(Errors.NONE, env.cluster().controller()));
env.kafkaClient().prepareResponse(new LeaveGroupResponse(
new LeaveGroupResponseData().setErrorCode(Errors.NONE.code()).setMembers(
Arrays.asList(responseOne, responseTwo))
));
final RemoveMembersFromConsumerGroupResult partialFailureResults = env.adminClient().removeMembersFromConsumerGroup(
groupId,
new RemoveMembersFromConsumerGroupOptions()
);
ExecutionException exception = assertThrows(ExecutionException.class, () -> partialFailureResults.all().get());

@feyman2016 feyman2016 May 23, 2020

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No existing help method to assert the cause of exception throw by all(). Also I think it's more straight forward in this way.

assertTrue(exception.getCause() instanceof KafkaException);
assertTrue(exception.getCause().getCause() instanceof UnknownMemberIdException);

// Return with success for "removeAll" scenario
// 1 prepare response for AdminClient.describeConsumerGroups
env.kafkaClient().prepareResponse(prepareFindCoordinatorResponse(Errors.NONE, env.cluster().controller()));
env.kafkaClient().prepareResponse(new DescribeGroupsResponse(data));

// 2. KafkaAdminClient should delete all members correctly
env.kafkaClient().prepareResponse(prepareFindCoordinatorResponse(Errors.NONE, env.cluster().controller()));
env.kafkaClient().prepareResponse(new LeaveGroupResponse(
new LeaveGroupResponseData().setErrorCode(Errors.NONE.code()).setMembers(
Arrays.asList(responseTwo,
new MemberResponse().setGroupInstanceId(instanceOne).setErrorCode(Errors.NONE.code())
))
));
final RemoveMembersFromConsumerGroupResult successResult = env.adminClient().removeMembersFromConsumerGroup(
groupId,
new RemoveMembersFromConsumerGroupOptions()
);
assertNull(successResult.all().get());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.util.Collections;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

public class RemoveMembersFromConsumerGroupOptionsTest {

Expand All @@ -31,5 +32,8 @@ public void testConstructor() {

assertEquals(Collections.singleton(
new MemberToRemove("instance-1")), options.members());

// Construct will fail if illegal empty members provided
assertThrows(IllegalArgumentException.class, () -> new RemoveMembersFromConsumerGroupOptions(Collections.emptyList()));
}
}
32 changes: 25 additions & 7 deletions core/src/main/scala/kafka/tools/StreamsResetter.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.apache.kafka.clients.admin.DescribeConsumerGroupsOptions;
import org.apache.kafka.clients.admin.DescribeConsumerGroupsResult;
import org.apache.kafka.clients.admin.MemberDescription;
import org.apache.kafka.clients.admin.RemoveMembersFromConsumerGroupOptions;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.KafkaConsumer;
Expand Down Expand Up @@ -106,6 +107,7 @@ public class StreamsResetter {
private static OptionSpec versionOption;
private static OptionSpecBuilder executeOption;
private static OptionSpec<String> commandConfigOption;
private static OptionSpec forceOption;

private static String usage = "This tool helps to quickly reset an application in order to reprocess "
+ "its data from scratch.\n"
Expand All @@ -119,7 +121,11 @@ public class StreamsResetter {
+ "* This tool will not clean up the local state on the stream application instances (the persisted "
+ "stores used to cache aggregation results).\n"
+ "You need to call KafkaStreams#cleanUp() in your application or manually delete them from the "
+ "directory specified by \"state.dir\" configuration (/tmp/kafka-streams/<application.id> by default).\n\n"
+ "directory specified by \"state.dir\" configuration (/tmp/kafka-streams/<application.id> by default).\n"
+ "* When long session timeout has been configured, active members could take longer to get expired on the "
+ "broker thus blocking the reset job to complete. Use the \"--force\" option could remove those left-over "
+ "members immediately. Make sure to stop all stream applications when this option is specified "
+ "to avoid unexpected disruptions.\n\n"
+ "*** Important! You will get wrong output if you don't clean up the local stores after running the "
+ "reset tool!\n\n";

Expand Down Expand Up @@ -149,7 +155,7 @@ public int run(final String[] args,
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, options.valueOf(bootstrapServerOption));

adminClient = Admin.create(properties);
validateNoActiveConsumers(groupId, adminClient);
maybeDeleteActiveConsumers(groupId, adminClient);

allTopics.clear();
allTopics.addAll(adminClient.listTopics().names().get(60, TimeUnit.SECONDS));
Expand All @@ -176,8 +182,8 @@ public int run(final String[] args,
return exitCode;
}

private void validateNoActiveConsumers(final String groupId,
final Admin adminClient)
private void maybeDeleteActiveConsumers(final String groupId,
final Admin adminClient)
throws ExecutionException, InterruptedException {

final DescribeConsumerGroupsResult describeResult = adminClient.describeConsumerGroups(
Expand All @@ -186,9 +192,19 @@ private void validateNoActiveConsumers(final String groupId,
final List<MemberDescription> members =
new ArrayList<>(describeResult.describedGroups().get(groupId).get().members());
if (!members.isEmpty()) {
throw new IllegalStateException("Consumer group '" + groupId + "' is still active "
+ "and has following members: " + members + ". "
+ "Make sure to stop all running application instances before running the reset tool.");
if (options.has(forceOption)) {
System.out.println("Force deleting all active members in the group: " + groupId);
try {
adminClient.removeMembersFromConsumerGroup(groupId, new RemoveMembersFromConsumerGroupOptions()).all().get();
} catch (Exception e) {
throw e;
}
} else {
throw new IllegalStateException("Consumer group '" + groupId + "' is still active "
+ "and has following members: " + members + ". "
+ "Make sure to stop all running application instances before running the reset tool."
+ " You can use option '--force' to remove active members from the group.");
}
}
}

Expand Down Expand Up @@ -236,6 +252,8 @@ private void parseArguments(final String[] args) {
.withRequiredArg()
.ofType(String.class)
.describedAs("file name");
forceOption = optionParser.accepts("force", "Force the removal of members of the consumer group (intended to remove stopped members if a long session timeout was used). " +
"Make sure to shut down all stream applications when this option is specified to avoid unexpected rebalances.");
executeOption = optionParser.accepts("execute", "Execute the command.");
dryRunOption = optionParser.accepts("dry-run", "Display the actions that would be performed without executing the reset commands.");
helpOption = optionParser.accepts("help", "Print usage information.").forHelp();
Expand Down
Loading