Browse Source

KAFKA-2369: Add REST API for Copycat.

Author: Ewen Cheslack-Postava <me@ewencp.org>

Reviewers: Gwen Shapira, James Cheng

Closes #378 from ewencp/kafka-2369-copycat-rest-api
pull/378/merge
Ewen Cheslack-Postava 9 years ago committed by Gwen Shapira
parent
commit
c001b2040c
  1. 84
      build.gradle
  2. 10
      checkstyle/import-control.xml
  3. 35
      copycat/runtime/src/main/java/org/apache/kafka/copycat/cli/CopycatDistributed.java
  4. 21
      copycat/runtime/src/main/java/org/apache/kafka/copycat/cli/CopycatStandalone.java
  5. 35
      copycat/runtime/src/main/java/org/apache/kafka/copycat/errors/AlreadyExistsException.java
  6. 35
      copycat/runtime/src/main/java/org/apache/kafka/copycat/errors/NotFoundException.java
  7. 7
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Copycat.java
  8. 92
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Herder.java
  9. 1
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/SourceTaskOffsetCommitter.java
  10. 15
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Worker.java
  11. 46
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerConfig.java
  12. 1
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSinkTask.java
  13. 1
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSinkTaskThread.java
  14. 1
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSourceTask.java
  15. 11
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/ClusterConfigState.java
  16. 43
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/CopycatProtocol.java
  17. 14
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/DistributedConfig.java
  18. 351
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/DistributedHerder.java
  19. 19
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/NotLeaderException.java
  20. 23
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/WorkerCoordinator.java
  21. 9
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/WorkerGroupMember.java
  22. 258
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/RestServer.java
  23. 81
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ConnectorInfo.java
  24. 59
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/CreateConnectorRequest.java
  25. 63
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ErrorMessage.java
  26. 41
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ServerInfo.java
  27. 58
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/TaskInfo.java
  28. 60
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/errors/CopycatExceptionMapper.java
  29. 70
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/errors/CopycatRestException.java
  30. 201
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/resources/ConnectorsResource.java
  31. 36
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/resources/RootResource.java
  32. 35
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/standalone/StandaloneConfig.java
  33. 199
      copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/standalone/StandaloneHerder.java
  34. 10
      copycat/runtime/src/main/java/org/apache/kafka/copycat/util/ConnectorTaskId.java
  35. 3
      copycat/runtime/src/main/java/org/apache/kafka/copycat/util/ConvertingFutureCallback.java
  36. 4
      copycat/runtime/src/main/java/org/apache/kafka/copycat/util/FutureCallback.java
  37. 4
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerSinkTaskTest.java
  38. 4
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerSourceTaskTest.java
  39. 10
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerTest.java
  40. 236
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/distributed/DistributedHerderTest.java
  41. 21
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/distributed/WorkerCoordinatorTest.java
  42. 364
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/rest/resources/ConnectorsResourceTest.java
  43. 228
      copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/standalone/StandaloneHerderTest.java
  44. 11
      copycat/runtime/src/test/java/org/apache/kafka/copycat/storage/KafkaConfigStorageTest.java
  45. 81
      tests/kafkatest/services/copycat.py
  46. 8
      tests/kafkatest/tests/copycat_distributed_test.py
  47. 163
      tests/kafkatest/tests/copycat_rest_test.py
  48. 4
      tests/kafkatest/tests/copycat_test.py
  49. 2
      tests/kafkatest/tests/templates/copycat-distributed.properties
  50. 2
      tests/setup.py
  51. 4
      vagrant/system-test-Vagrantfile.local

84
build.gradle

@ -33,6 +33,9 @@ def junit='junit:junit:4.11' @@ -33,6 +33,9 @@ def junit='junit:junit:4.11'
def easymock='org.easymock:easymock:3.3.1'
def powermock='org.powermock:powermock-module-junit4:1.6.2'
def powermock_easymock='org.powermock:powermock-api-easymock:1.6.2'
def jackson_version = '2.5.4'
def jetty_version = '9.2.12.v20150709'
def jersey_version = '2.22.1'
allprojects {
apply plugin: 'idea'
@ -501,7 +504,7 @@ project(':tools') { @@ -501,7 +504,7 @@ project(':tools') {
dependencies {
compile project(':clients')
compile 'net.sourceforge.argparse4j:argparse4j:0.5.0'
compile 'com.fasterxml.jackson.core:jackson-databind:2.5.4'
compile "com.fasterxml.jackson.core:jackson-databind:$jackson_version"
compile "$slf4jlog4j"
testCompile "$junit"
@ -671,6 +674,21 @@ project(':copycat:api') { @@ -671,6 +674,21 @@ project(':copycat:api') {
include "**/org/apache/kafka/copycat/*"
}
tasks.create(name: "copyDependantLibs", type: Copy) {
from (configurations.testRuntime) {
include('slf4j-log4j12*')
}
from (configurations.runtime) {
exclude('kafka-clients*')
exclude('copycat-*')
}
into "$buildDir/dependant-libs"
}
jar {
dependsOn copyDependantLibs
}
artifacts {
archives testJar
}
@ -692,7 +710,7 @@ project(':copycat:json') { @@ -692,7 +710,7 @@ project(':copycat:json') {
dependencies {
compile project(':copycat:api')
compile "$slf4japi"
compile 'com.fasterxml.jackson.core:jackson-databind:2.5.4'
compile "com.fasterxml.jackson.core:jackson-databind:$jackson_version"
testCompile "$junit"
testCompile "$easymock"
@ -717,6 +735,21 @@ project(':copycat:json') { @@ -717,6 +735,21 @@ project(':copycat:json') {
include "**/org/apache/kafka/copycat/*"
}
tasks.create(name: "copyDependantLibs", type: Copy) {
from (configurations.testRuntime) {
include('slf4j-log4j12*')
}
from (configurations.runtime) {
exclude('kafka-clients*')
exclude('copycat-*')
}
into "$buildDir/dependant-libs"
}
jar {
dependsOn copyDependantLibs
}
artifacts {
archives testJar
}
@ -729,18 +762,6 @@ project(':copycat:json') { @@ -729,18 +762,6 @@ project(':copycat:json') {
configFile = new File(rootDir, "checkstyle/checkstyle.xml")
}
test.dependsOn('checkstyleMain', 'checkstyleTest')
tasks.create(name: "copyDependantLibs", type: Copy) {
from (configurations.runtime) {
exclude('kafka-clients*')
exclude('copycat-*')
}
into "$buildDir/dependant-libs"
}
jar {
dependsOn copyDependantLibs
}
}
project(':copycat:runtime') {
@ -752,6 +773,11 @@ project(':copycat:runtime') { @@ -752,6 +773,11 @@ project(':copycat:runtime') {
compile project(':clients')
compile "$slf4japi"
compile "org.eclipse.jetty:jetty-server:$jetty_version"
compile "org.eclipse.jetty:jetty-servlet:$jetty_version"
compile "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:$jackson_version"
compile "org.glassfish.jersey.containers:jersey-container-servlet:$jersey_version"
testCompile "$junit"
testCompile "$easymock"
testCompile "$powermock"
@ -777,6 +803,21 @@ project(':copycat:runtime') { @@ -777,6 +803,21 @@ project(':copycat:runtime') {
include "**/org/apache/kafka/copycat/*"
}
tasks.create(name: "copyDependantLibs", type: Copy) {
from (configurations.testRuntime) {
include('slf4j-log4j12*')
}
from (configurations.runtime) {
exclude('kafka-clients*')
exclude('copycat-*')
}
into "$buildDir/dependant-libs"
}
jar {
dependsOn copyDependantLibs
}
artifacts {
archives testJar
}
@ -822,6 +863,21 @@ project(':copycat:file') { @@ -822,6 +863,21 @@ project(':copycat:file') {
include "**/org/apache/kafka/copycat/*"
}
tasks.create(name: "copyDependantLibs", type: Copy) {
from (configurations.testRuntime) {
include('slf4j-log4j12*')
}
from (configurations.runtime) {
exclude('kafka-clients*')
exclude('copycat-*')
}
into "$buildDir/dependant-libs"
}
jar {
dependsOn copyDependantLibs
}
artifacts {
archives testJar
}

10
checkstyle/import-control.xml

@ -161,6 +161,14 @@ @@ -161,6 +161,14 @@
<subpackage name="runtime">
<allow pkg="org.apache.kafka.copycat" />
<subpackage name="rest">
<allow pkg="org.eclipse.jetty" />
<allow pkg="javax.ws.rs" />
<allow pkg="javax.servlet" />
<allow pkg="org.glassfish.jersey" />
<allow pkg="com.fasterxml.jackson" />
</subpackage>
</subpackage>
<subpackage name="cli">
@ -177,6 +185,8 @@ @@ -177,6 +185,8 @@
<subpackage name="util">
<allow pkg="org.apache.kafka.copycat" />
<!-- for annotations to avoid code duplication -->
<allow pkg="com.fasterxml.jackson.annotation" />
</subpackage>
<subpackage name="json">

35
copycat/runtime/src/main/java/org/apache/kafka/copycat/cli/CopycatDistributed.java

@ -21,14 +21,13 @@ import org.apache.kafka.common.annotation.InterfaceStability; @@ -21,14 +21,13 @@ import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.runtime.Copycat;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.distributed.DistributedConfig;
import org.apache.kafka.copycat.runtime.distributed.DistributedHerder;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.apache.kafka.copycat.storage.KafkaOffsetBackingStore;
import org.apache.kafka.copycat.util.Callback;
import org.apache.kafka.copycat.util.FutureCallback;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Properties;
/**
@ -46,40 +45,22 @@ public class CopycatDistributed { @@ -46,40 +45,22 @@ public class CopycatDistributed {
public static void main(String[] args) throws Exception {
Properties workerProps;
Properties connectorProps;
if (args.length < 1) {
log.info("Usage: CopycatDistributed worker.properties [connector1.properties connector2.properties ...]");
log.info("Usage: CopycatDistributed worker.properties");
System.exit(1);
}
String workerPropsFile = args[0];
workerProps = !workerPropsFile.isEmpty() ? Utils.loadProps(workerPropsFile) : new Properties();
WorkerConfig workerConfig = new WorkerConfig(workerProps);
Worker worker = new Worker(workerConfig, new KafkaOffsetBackingStore());
DistributedHerder herder = new DistributedHerder(worker, workerConfig.originals());
final Copycat copycat = new Copycat(worker, herder);
DistributedConfig config = new DistributedConfig(workerProps);
Worker worker = new Worker(config, new KafkaOffsetBackingStore());
RestServer rest = new RestServer(config);
DistributedHerder herder = new DistributedHerder(config, worker, rest.advertisedUrl());
final Copycat copycat = new Copycat(worker, herder, rest);
copycat.start();
try {
for (final String connectorPropsFile : Arrays.copyOfRange(args, 1, args.length)) {
connectorProps = Utils.loadProps(connectorPropsFile);
FutureCallback<String> cb = new FutureCallback<>(new Callback<String>() {
@Override
public void onCompletion(Throwable error, String id) {
if (error != null)
log.error("Failed to create job for {}", connectorPropsFile);
}
});
herder.addConnector(Utils.propsToStringMap(connectorProps), cb);
cb.get();
}
} catch (Throwable t) {
log.error("Stopping after connector error", t);
copycat.stop();
}
// Shutdown will be triggered by Ctrl-C or via HTTP shutdown request
copycat.awaitStop();
}

21
copycat/runtime/src/main/java/org/apache/kafka/copycat/cli/CopycatStandalone.java

@ -19,9 +19,13 @@ package org.apache.kafka.copycat.cli; @@ -19,9 +19,13 @@ package org.apache.kafka.copycat.cli;
import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Copycat;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.standalone.StandaloneConfig;
import org.apache.kafka.copycat.runtime.standalone.StandaloneHerder;
import org.apache.kafka.copycat.storage.FileOffsetBackingStore;
import org.apache.kafka.copycat.util.Callback;
@ -59,23 +63,28 @@ public class CopycatStandalone { @@ -59,23 +63,28 @@ public class CopycatStandalone {
String workerPropsFile = args[0];
workerProps = !workerPropsFile.isEmpty() ? Utils.loadProps(workerPropsFile) : new Properties();
WorkerConfig workerConfig = new WorkerConfig(workerProps);
Worker worker = new Worker(workerConfig, new FileOffsetBackingStore());
StandaloneConfig config = new StandaloneConfig(workerProps);
Worker worker = new Worker(config, new FileOffsetBackingStore());
RestServer rest = new RestServer(config);
Herder herder = new StandaloneHerder(worker);
final Copycat copycat = new Copycat(worker, herder);
final Copycat copycat = new Copycat(worker, herder, rest);
copycat.start();
try {
for (final String connectorPropsFile : Arrays.copyOfRange(args, 1, args.length)) {
connectorProps = Utils.loadProps(connectorPropsFile);
FutureCallback<String> cb = new FutureCallback<>(new Callback<String>() {
FutureCallback<Herder.Created<ConnectorInfo>> cb = new FutureCallback<>(new Callback<Herder.Created<ConnectorInfo>>() {
@Override
public void onCompletion(Throwable error, String id) {
public void onCompletion(Throwable error, Herder.Created<ConnectorInfo> info) {
if (error != null)
log.error("Failed to create job for {}", connectorPropsFile);
else
log.info("Created connector {}", info.result().name());
}
});
herder.addConnector(Utils.propsToStringMap(connectorProps), cb);
herder.putConnectorConfig(
connectorProps.getProperty(ConnectorConfig.NAME_CONFIG),
Utils.propsToStringMap(connectorProps), false, cb);
cb.get();
}
} catch (Throwable t) {

35
copycat/runtime/src/main/java/org/apache/kafka/copycat/errors/AlreadyExistsException.java

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
/**
* 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.copycat.errors;
/**
* Indicates the operation tried to create an entity that already exists.
*/
public class AlreadyExistsException extends CopycatException {
public AlreadyExistsException(String s) {
super(s);
}
public AlreadyExistsException(String s, Throwable throwable) {
super(s, throwable);
}
public AlreadyExistsException(Throwable throwable) {
super(throwable);
}
}

35
copycat/runtime/src/main/java/org/apache/kafka/copycat/errors/NotFoundException.java

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
/**
* 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.copycat.errors;
/**
* Indicates that an operation attempted to modify or delete a connector or task that is not present on the worker.
*/
public class NotFoundException extends CopycatException {
public NotFoundException(String s) {
super(s);
}
public NotFoundException(String s, Throwable throwable) {
super(s, throwable);
}
public NotFoundException(Throwable throwable) {
super(throwable);
}
}

7
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Copycat.java

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -34,15 +35,17 @@ public class Copycat { @@ -34,15 +35,17 @@ public class Copycat {
private final Worker worker;
private final Herder herder;
private final RestServer rest;
private final CountDownLatch startLatch = new CountDownLatch(1);
private final CountDownLatch stopLatch = new CountDownLatch(1);
private final AtomicBoolean shutdown = new AtomicBoolean(false);
private final ShutdownHook shutdownHook;
public Copycat(Worker worker, Herder herder) {
public Copycat(Worker worker, Herder herder, RestServer rest) {
log.debug("Copycat created");
this.worker = worker;
this.herder = herder;
this.rest = rest;
shutdownHook = new ShutdownHook();
}
@ -52,6 +55,7 @@ public class Copycat { @@ -52,6 +55,7 @@ public class Copycat {
worker.start();
herder.start();
rest.start(herder);
log.info("Copycat started");
@ -63,6 +67,7 @@ public class Copycat { @@ -63,6 +67,7 @@ public class Copycat {
if (!wasShuttingDown) {
log.info("Copycat stopping");
rest.stop();
herder.stop();
worker.stop();

92
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Herder.java

@ -17,9 +17,14 @@ @@ -17,9 +17,14 @@
package org.apache.kafka.copycat.runtime;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.util.Callback;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* <p>
@ -49,21 +54,39 @@ public interface Herder { @@ -49,21 +54,39 @@ public interface Herder {
void stop();
/**
* Submit a connector job to the cluster. This works from any node by forwarding the request to
* the leader herder if necessary.
* Get a list of connectors currently running in this cluster. This is a full list of connectors in the cluster gathered
* from the current configuration. However, note
*
* @param connectorProps user-specified properties for this job
* @param callback callback to invoke when the request completes
* @returns A list of connector names
* @throws org.apache.kafka.copycat.runtime.distributed.NotLeaderException if this node can not resolve the request
* (e.g., because it has not joined the cluster or does not have configs in sync with the group) and it is
* also not the leader
* @throws org.apache.kafka.copycat.errors.CopycatException if this node is the leader, but still cannot resolve the
* request (e.g., it is not in sync with other worker's config state)
*/
void addConnector(Map<String, String> connectorProps, Callback<String> callback);
void connectors(Callback<Collection<String>> callback);
/**
* Delete a connector job by name.
*
* @param name name of the connector job to shutdown and delete
* @param callback callback to invoke when the request completes
* Get the definition and status of a connector.
*/
void connectorInfo(String connName, Callback<ConnectorInfo> callback);
/**
* Get the configuration for a connector.
* @param connName name of the connector
* @param callback callback to invoke with the configuration
*/
void connectorConfig(String connName, Callback<Map<String, String>> callback);
/**
* Set the configuration for a connector. This supports creation, update, and deletion.
* @param connName name of the connector
* @param config the connectors configuration, or null if deleting the connector
* @param allowReplace if true, allow overwriting previous configs; if false, throw AlreadyExistsException if a connector
* with the same name already exists
* @param callback callback to invoke when the configuration has been written
*/
void deleteConnector(String name, Callback<Void> callback);
void putConnectorConfig(String connName, Map<String, String> config, boolean allowReplace, Callback<Created<ConnectorInfo>> callback);
/**
* Requests reconfiguration of the task. This should only be triggered by
@ -73,4 +96,53 @@ public interface Herder { @@ -73,4 +96,53 @@ public interface Herder {
*/
void requestTaskReconfiguration(String connName);
/**
* Get the configurations for the current set of tasks of a connector.
* @param connName connector to update
* @param callback callback to invoke upon completion
*/
void taskConfigs(String connName, Callback<List<TaskInfo>> callback);
/**
* Set the configurations for the tasks of a connector. This should always include all tasks in the connector; if
* there are existing configurations and fewer are provided, this will reduce the number of tasks, and if more are
* provided it will increase the number of tasks.
* @param connName connector to update
* @param configs list of configurations
* @param callback callback to invoke upon completion
*/
void putTaskConfigs(String connName, List<Map<String, String>> configs, Callback<Void> callback);
class Created<T> {
private final boolean created;
private final T result;
public Created(boolean created, T result) {
this.created = created;
this.result = result;
}
public boolean created() {
return created;
}
public T result() {
return result;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Created<?> created1 = (Created<?>) o;
return Objects.equals(created, created1.created) &&
Objects.equals(result, created1.result);
}
@Override
public int hashCode() {
return Objects.hash(created, result);
}
}
}

1
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/SourceTaskOffsetCommitter.java

@ -18,7 +18,6 @@ @@ -18,7 +18,6 @@
package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

15
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/Worker.java

@ -23,7 +23,6 @@ import org.apache.kafka.common.utils.Time; @@ -23,7 +23,6 @@ import org.apache.kafka.common.utils.Time;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.connector.Connector;
import org.apache.kafka.copycat.connector.ConnectorContext;
import org.apache.kafka.copycat.connector.Task;
@ -35,6 +34,7 @@ import org.apache.kafka.copycat.util.ConnectorTaskId; @@ -35,6 +34,7 @@ import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -150,6 +150,10 @@ public class Worker { @@ -150,6 +150,10 @@ public class Worker {
log.info("Worker stopped");
}
public WorkerConfig config() {
return config;
}
/**
* Add a new connector.
* @param connConfig connector configuration
@ -196,24 +200,21 @@ public class Worker { @@ -196,24 +200,21 @@ public class Worker {
}
}
public Map<ConnectorTaskId, Map<String, String>> reconfigureConnectorTasks(String connName, int maxTasks, List<String> sinkTopics) {
public List<Map<String, String>> connectorTaskConfigs(String connName, int maxTasks, List<String> sinkTopics) {
log.trace("Reconfiguring connector tasks for {}", connName);
Connector connector = connectors.get(connName);
if (connector == null)
throw new CopycatException("Connector " + connName + " not found in this worker.");
Map<ConnectorTaskId, Map<String, String>> result = new HashMap<>();
List<Map<String, String>> result = new ArrayList<>();
String taskClassName = connector.taskClass().getName();
int index = 0;
for (Properties taskProps : connector.taskConfigs(maxTasks)) {
ConnectorTaskId taskId = new ConnectorTaskId(connName, index);
index++;
Map<String, String> taskConfig = Utils.propsToStringMap(taskProps);
taskConfig.put(TaskConfig.TASK_CLASS_CONFIG, taskClassName);
if (sinkTopics != null)
taskConfig.put(SinkTask.TOPICS_CONFIG, Utils.join(sinkTopics, ","));
result.put(taskId, taskConfig);
result.add(taskConfig);
}
return result;
}

46
copycat/runtime/src/main/java/org/apache/kafka/copycat/cli/WorkerConfig.java → copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerConfig.java

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
* limitations under the License.
**/
package org.apache.kafka.copycat.cli;
package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.config.AbstractConfig;
@ -26,7 +26,7 @@ import org.apache.kafka.common.config.ConfigDef.Type; @@ -26,7 +26,7 @@ import org.apache.kafka.common.config.ConfigDef.Type;
import java.util.Properties;
/**
* Configuration for standalone workers.
* Common base class providing configuration for Copycat workers, whether standalone or distributed.
*/
@InterfaceStability.Unstable
public class WorkerConfig extends AbstractConfig {
@ -84,10 +84,30 @@ public class WorkerConfig extends AbstractConfig { @@ -84,10 +84,30 @@ public class WorkerConfig extends AbstractConfig {
+ "data to be committed in a future attempt.";
public static final long OFFSET_COMMIT_TIMEOUT_MS_DEFAULT = 5000L;
private static ConfigDef config;
static {
config = new ConfigDef()
public static final String REST_HOST_NAME_CONFIG = "rest.host.name";
private static final String REST_HOST_NAME_DOC
= "Hostname for the REST API. If this is set, it will only bind to this interface.";
public static final String REST_PORT_CONFIG = "rest.port";
private static final String REST_PORT_DOC
= "Port for the REST API to listen on.";
public static final int REST_PORT_DEFAULT = 8083;
public static final String REST_ADVERTISED_HOST_NAME_CONFIG = "rest.advertised.host.name";
private static final String REST_ADVERTISED_HOST_NAME_DOC
= "If this is set, this is the hostname that will be given out to other workers to connect to.";
public static final String REST_ADVERTISED_PORT_CONFIG = "rest.advertised.port";
private static final String REST_ADVERTISED_PORT_DOC
= "If this is set, this is the port that will be given out to other workers to connect to.";
/**
* Get a basic ConfigDef for a WorkerConfig. This includes all the common settings. Subclasses can use this to
* bootstrap their own ConfigDef.
* @return a ConfigDef with all the common options specified
*/
protected static ConfigDef baseConfigDef() {
return new ConfigDef()
.define(CLUSTER_CONFIG, Type.STRING, CLUSTER_DEFAULT, Importance.HIGH, CLUSTER_CONFIG_DOC)
.define(BOOTSTRAP_SERVERS_CONFIG, Type.LIST, BOOTSTRAP_SERVERS_DEFAULT,
Importance.HIGH, BOOTSTRAP_SERVERS_DOC)
@ -105,14 +125,14 @@ public class WorkerConfig extends AbstractConfig { @@ -105,14 +125,14 @@ public class WorkerConfig extends AbstractConfig {
.define(OFFSET_COMMIT_INTERVAL_MS_CONFIG, Type.LONG, OFFSET_COMMIT_INTERVAL_MS_DEFAULT,
Importance.LOW, OFFSET_COMMIT_INTERVAL_MS_DOC)
.define(OFFSET_COMMIT_TIMEOUT_MS_CONFIG, Type.LONG, OFFSET_COMMIT_TIMEOUT_MS_DEFAULT,
Importance.LOW, OFFSET_COMMIT_TIMEOUT_MS_DOC);
}
public WorkerConfig() {
this(new Properties());
Importance.LOW, OFFSET_COMMIT_TIMEOUT_MS_DOC)
.define(REST_HOST_NAME_CONFIG, Type.STRING, Importance.LOW, REST_HOST_NAME_DOC, false)
.define(REST_PORT_CONFIG, Type.INT, REST_PORT_DEFAULT, Importance.LOW, REST_PORT_DOC)
.define(REST_ADVERTISED_HOST_NAME_CONFIG, Type.STRING, Importance.LOW, REST_ADVERTISED_HOST_NAME_DOC, false)
.define(REST_ADVERTISED_PORT_CONFIG, Type.INT, Importance.LOW, REST_ADVERTISED_PORT_DOC, false);
}
public WorkerConfig(Properties props) {
super(config, props);
public WorkerConfig(ConfigDef definition, Properties props) {
super(definition, props);
}
}

1
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSinkTask.java

@ -23,7 +23,6 @@ import org.apache.kafka.common.errors.WakeupException; @@ -23,7 +23,6 @@ import org.apache.kafka.common.errors.WakeupException;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.data.SchemaAndValue;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.errors.IllegalWorkerStateException;

1
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSinkTaskThread.java

@ -18,7 +18,6 @@ @@ -18,7 +18,6 @@
package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.util.ShutdownableThread;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

1
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/WorkerSourceTask.java

@ -22,7 +22,6 @@ import org.apache.kafka.clients.producer.Callback; @@ -22,7 +22,6 @@ import org.apache.kafka.clients.producer.Callback;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.source.SourceRecord;
import org.apache.kafka.copycat.source.SourceTask;
import org.apache.kafka.copycat.source.SourceTaskContext;

11
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/ClusterConfigState.java

@ -19,8 +19,9 @@ package org.apache.kafka.copycat.runtime.distributed; @@ -19,8 +19,9 @@ package org.apache.kafka.copycat.runtime.distributed;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -99,15 +100,15 @@ public class ClusterConfigState { @@ -99,15 +100,15 @@ public class ClusterConfigState {
* @param connectorName the name of the connector to look up task configs for
* @return the current set of connector task IDs
*/
public Set<ConnectorTaskId> tasks(String connectorName) {
public List<ConnectorTaskId> tasks(String connectorName) {
if (inconsistentConnectors.contains(connectorName))
return Collections.emptySet();
return Collections.emptyList();
Integer numTasks = connectorTaskCounts.get(connectorName);
if (numTasks == null)
return Collections.emptySet();
return Collections.emptyList();
Set<ConnectorTaskId> taskIds = new HashSet<>();
List<ConnectorTaskId> taskIds = new ArrayList<>();
for (int taskIndex = 0; taskIndex < numTasks; taskIndex++) {
ConnectorTaskId taskId = new ConnectorTaskId(connectorName, taskIndex);
taskIds.add(taskId);

43
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/CopycatProtocol.java

@ -38,9 +38,11 @@ import java.util.Map; @@ -38,9 +38,11 @@ import java.util.Map;
*/
public class CopycatProtocol {
public static final String VERSION_KEY_NAME = "version";
public static final String URL_KEY_NAME = "url";
public static final String CONFIG_OFFSET_KEY_NAME = "config-offset";
public static final String CONNECTOR_KEY_NAME = "connector";
public static final String LEADER_KEY_NAME = "leader";
public static final String LEADER_URL_KEY_NAME = "leader-url";
public static final String ERROR_KEY_NAME = "error";
public static final String TASKS_KEY_NAME = "tasks";
public static final String ASSIGNMENT_KEY_NAME = "assignment";
@ -53,7 +55,9 @@ public class CopycatProtocol { @@ -53,7 +55,9 @@ public class CopycatProtocol {
.set(VERSION_KEY_NAME, COPYCAT_PROTOCOL_V0);
public static final Schema CONFIG_STATE_V0 = new Schema(
new Field(URL_KEY_NAME, Type.STRING),
new Field(CONFIG_OFFSET_KEY_NAME, Type.INT64));
// Assignments for each worker are a set of connectors and tasks. These are categorized by connector ID. A sentinel
// task ID (CONNECTOR_TASK) is used to indicate the connector itself (i.e. that the assignment includes
// responsibility for running the Connector instance in addition to any tasks it generates).
@ -63,12 +67,14 @@ public class CopycatProtocol { @@ -63,12 +67,14 @@ public class CopycatProtocol {
public static final Schema ASSIGNMENT_V0 = new Schema(
new Field(ERROR_KEY_NAME, Type.INT16),
new Field(LEADER_KEY_NAME, Type.STRING),
new Field(LEADER_URL_KEY_NAME, Type.STRING),
new Field(CONFIG_OFFSET_KEY_NAME, Type.INT64),
new Field(ASSIGNMENT_KEY_NAME, new ArrayOf(CONNECTOR_ASSIGNMENT_V0)));
public static ByteBuffer serializeMetadata(ConfigState configState) {
public static ByteBuffer serializeMetadata(WorkerState workerState) {
Struct struct = new Struct(CONFIG_STATE_V0);
struct.set(CONFIG_OFFSET_KEY_NAME, configState.offset());
struct.set(URL_KEY_NAME, workerState.url());
struct.set(CONFIG_OFFSET_KEY_NAME, workerState.offset());
ByteBuffer buffer = ByteBuffer.allocate(COPYCAT_PROTOCOL_HEADER_V0.sizeOf() + CONFIG_STATE_V0.sizeOf(struct));
COPYCAT_PROTOCOL_HEADER_V0.writeTo(buffer);
CONFIG_STATE_V0.write(buffer, struct);
@ -76,19 +82,21 @@ public class CopycatProtocol { @@ -76,19 +82,21 @@ public class CopycatProtocol {
return buffer;
}
public static ConfigState deserializeMetadata(ByteBuffer buffer) {
public static WorkerState deserializeMetadata(ByteBuffer buffer) {
Struct header = (Struct) COPYCAT_PROTOCOL_HEADER_SCHEMA.read(buffer);
Short version = header.getShort(VERSION_KEY_NAME);
checkVersionCompatibility(version);
Struct struct = (Struct) CONFIG_STATE_V0.read(buffer);
long configOffset = struct.getLong(CONFIG_OFFSET_KEY_NAME);
return new ConfigState(configOffset);
String url = struct.getString(URL_KEY_NAME);
return new WorkerState(url, configOffset);
}
public static ByteBuffer serializeAssignment(Assignment assignment) {
Struct struct = new Struct(ASSIGNMENT_V0);
struct.set(ERROR_KEY_NAME, assignment.error());
struct.set(LEADER_KEY_NAME, assignment.leader());
struct.set(LEADER_URL_KEY_NAME, assignment.leaderUrl());
struct.set(CONFIG_OFFSET_KEY_NAME, assignment.offset());
List<Struct> taskAssignments = new ArrayList<>();
for (Map.Entry<String, List<Integer>> connectorEntry : assignment.asMap().entrySet()) {
@ -114,6 +122,7 @@ public class CopycatProtocol { @@ -114,6 +122,7 @@ public class CopycatProtocol {
Struct struct = (Struct) ASSIGNMENT_V0.read(buffer);
short error = struct.getShort(ERROR_KEY_NAME);
String leader = struct.getString(LEADER_KEY_NAME);
String leaderUrl = struct.getString(LEADER_URL_KEY_NAME);
long offset = struct.getLong(CONFIG_OFFSET_KEY_NAME);
List<String> connectorIds = new ArrayList<>();
List<ConnectorTaskId> taskIds = new ArrayList<>();
@ -128,24 +137,31 @@ public class CopycatProtocol { @@ -128,24 +137,31 @@ public class CopycatProtocol {
taskIds.add(new ConnectorTaskId(connector, taskId));
}
}
return new Assignment(error, leader, offset, connectorIds, taskIds);
return new Assignment(error, leader, leaderUrl, offset, connectorIds, taskIds);
}
public static class ConfigState {
public static class WorkerState {
private final String url;
private final long offset;
public ConfigState(long offset) {
public WorkerState(String url, long offset) {
this.url = url;
this.offset = offset;
}
public String url() {
return url;
}
public long offset() {
return offset;
}
@Override
public String toString() {
return "ConfigState{" +
"offset=" + offset +
return "WorkerState{" +
"url='" + url + '\'' +
", offset=" + offset +
'}';
}
}
@ -158,6 +174,7 @@ public class CopycatProtocol { @@ -158,6 +174,7 @@ public class CopycatProtocol {
private final short error;
private final String leader;
private final String leaderUrl;
private final long offset;
private final List<String> connectorIds;
private final List<ConnectorTaskId> taskIds;
@ -167,10 +184,11 @@ public class CopycatProtocol { @@ -167,10 +184,11 @@ public class CopycatProtocol {
* @param connectorIds list of connectors that the worker should instantiate and run
* @param taskIds list of task IDs that the worker should instantiate and run
*/
public Assignment(short error, String leader, long configOffset,
public Assignment(short error, String leader, String leaderUrl, long configOffset,
List<String> connectorIds, List<ConnectorTaskId> taskIds) {
this.error = error;
this.leader = leader;
this.leaderUrl = leaderUrl;
this.offset = configOffset;
this.taskIds = taskIds;
this.connectorIds = connectorIds;
@ -184,6 +202,10 @@ public class CopycatProtocol { @@ -184,6 +202,10 @@ public class CopycatProtocol {
return leader;
}
public String leaderUrl() {
return leaderUrl;
}
public boolean failed() {
return error != NO_ERROR;
}
@ -205,6 +227,7 @@ public class CopycatProtocol { @@ -205,6 +227,7 @@ public class CopycatProtocol {
return "Assignment{" +
"error=" + error +
", leader='" + leader + '\'' +
", leaderUrl='" + leaderUrl + '\'' +
", offset=" + offset +
", connectorIds=" + connectorIds +
", taskIds=" + taskIds +

14
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/DistributedHerderConfig.java → copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/DistributedConfig.java

@ -18,16 +18,16 @@ @@ -18,16 +18,16 @@
package org.apache.kafka.copycat.runtime.distributed;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.common.config.AbstractConfig;
import org.apache.kafka.common.config.ConfigDef;
import org.apache.kafka.common.config.SslConfigs;
import org.apache.kafka.common.config.SaslConfigs;
import org.apache.kafka.copycat.runtime.WorkerConfig;
import java.util.Map;
import java.util.Properties;
import static org.apache.kafka.common.config.ConfigDef.Range.atLeast;
public class DistributedHerderConfig extends AbstractConfig {
public class DistributedConfig extends WorkerConfig {
private static final ConfigDef CONFIG;
/*
@ -70,11 +70,7 @@ public class DistributedHerderConfig extends AbstractConfig { @@ -70,11 +70,7 @@ public class DistributedHerderConfig extends AbstractConfig {
public static final int WORKER_UNSYNC_BACKOFF_MS_DEFAULT = 5 * 60 * 1000;
static {
CONFIG = new ConfigDef()
.define(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG,
ConfigDef.Type.LIST,
ConfigDef.Importance.HIGH,
CommonClientConfigs.BOOSTRAP_SERVERS_DOC)
CONFIG = baseConfigDef()
.define(GROUP_ID_CONFIG, ConfigDef.Type.STRING, ConfigDef.Importance.HIGH, GROUP_ID_DOC)
.define(SESSION_TIMEOUT_MS_CONFIG,
ConfigDef.Type.INT,
@ -184,7 +180,7 @@ public class DistributedHerderConfig extends AbstractConfig { @@ -184,7 +180,7 @@ public class DistributedHerderConfig extends AbstractConfig {
WORKER_UNSYNC_BACKOFF_MS_DOC);
}
DistributedHerderConfig(Map<?, ?> props) {
public DistributedConfig(Properties props) {
super(CONFIG, props);
}

351
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/DistributedHerder.java

@ -21,12 +21,17 @@ import org.apache.kafka.common.errors.WakeupException; @@ -21,12 +21,17 @@ import org.apache.kafka.common.errors.WakeupException;
import org.apache.kafka.common.config.ConfigException;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.connector.ConnectorContext;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.errors.NotFoundException;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.HerderConnectorContext;
import org.apache.kafka.copycat.runtime.TaskConfig;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.sink.SinkConnector;
import org.apache.kafka.copycat.storage.KafkaConfigStorage;
import org.apache.kafka.copycat.util.Callback;
@ -34,7 +39,10 @@ import org.apache.kafka.copycat.util.ConnectorTaskId; @@ -34,7 +39,10 @@ import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@ -92,30 +100,29 @@ public class DistributedHerder implements Herder, Runnable { @@ -92,30 +100,29 @@ public class DistributedHerder implements Herder, Runnable {
private final Queue<HerderRequest> requests = new LinkedBlockingDeque<>();
// Config updates can be collected and applied together when possible. Also, we need to take care to rebalance when
// needed (e.g. task reconfiguration, which requires everyone to coordinate offset commits).
private final Set<String> connectorConfigUpdates = new HashSet<>();
private Set<String> connectorConfigUpdates = new HashSet<>();
private boolean needsReconfigRebalance;
public DistributedHerder(Worker worker, Map<String, ?> configs) {
this(worker, configs, null, null);
public DistributedHerder(DistributedConfig config, Worker worker, String restUrl) {
this(config, worker, null, null, restUrl);
}
// public for testing
public DistributedHerder(Worker worker, Map<String, ?> configs, KafkaConfigStorage configStorage, WorkerGroupMember member) {
public DistributedHerder(DistributedConfig config, Worker worker, KafkaConfigStorage configStorage, WorkerGroupMember member, String restUrl) {
this.worker = worker;
if (configStorage != null) {
// For testing. Assume configuration has already been performed
this.configStorage = configStorage;
} else {
this.configStorage = new KafkaConfigStorage(worker.getInternalValueConverter(), connectorConfigCallback(), taskConfigCallback());
this.configStorage.configure(configs);
this.configStorage.configure(config.originals());
}
configState = ClusterConfigState.EMPTY;
DistributedHerderConfig config = new DistributedHerderConfig(configs);
this.workerSyncTimeoutMs = config.getInt(DistributedHerderConfig.WORKER_SYNC_TIMEOUT_MS_CONFIG);
this.workerUnsyncBackoffMs = config.getInt(DistributedHerderConfig.WORKER_UNSYNC_BACKOFF_MS_CONFIG);
this.workerSyncTimeoutMs = config.getInt(DistributedConfig.WORKER_SYNC_TIMEOUT_MS_CONFIG);
this.workerUnsyncBackoffMs = config.getInt(DistributedConfig.WORKER_UNSYNC_BACKOFF_MS_CONFIG);
this.member = member != null ? member : new WorkerGroupMember(config, this.configStorage, rebalanceListener());
this.member = member != null ? member : new WorkerGroupMember(config, restUrl, this.configStorage, rebalanceListener());
stopping = new AtomicBoolean(false);
rebalanceResolved = true; // If we still need to follow up after a rebalance occurred, starting up tasks
@ -143,6 +150,9 @@ public class DistributedHerder implements Herder, Runnable { @@ -143,6 +150,9 @@ public class DistributedHerder implements Herder, Runnable {
halt();
log.info("Herder stopped");
} catch (Throwable t) {
log.error("Uncaught exception in herder work thread, exiting: ", t);
System.exit(1);
} finally {
stopLatch.countDown();
}
@ -169,14 +179,17 @@ public class DistributedHerder implements Herder, Runnable { @@ -169,14 +179,17 @@ public class DistributedHerder implements Herder, Runnable {
// Process any external requests
while (!requests.isEmpty()) {
HerderRequest request = requests.poll();
Callback<Void> cb = request.callback();
try {
request.callback().onCompletion(null, request.action().call());
request.action().call();
cb.onCompletion(null, null);
} catch (Throwable t) {
request.callback().onCompletion(t, null);
cb.onCompletion(t, null);
}
}
// Process any configuration updates
Set<String> connectorConfigUpdatesCopy = null;
synchronized (this) {
if (needsReconfigRebalance || !connectorConfigUpdates.isEmpty()) {
// Connector reconfigs only need local updates since there is no coordination between workers required.
@ -196,21 +209,31 @@ public class DistributedHerder implements Herder, Runnable { @@ -196,21 +209,31 @@ public class DistributedHerder implements Herder, Runnable {
needsReconfigRebalance = false;
return;
} else if (!connectorConfigUpdates.isEmpty()) {
// If we only have connector config updates, we can just bounce the updated connectors that are
// currently assigned to this worker.
Set<String> localConnectors = worker.connectorNames();
for (String connectorName : connectorConfigUpdates) {
if (!localConnectors.contains(connectorName))
continue;
worker.stopConnector(connectorName);
// The update may be a deletion, so verify we actually need to restart the connector
if (configState.connectors().contains(connectorName))
startConnector(connectorName);
}
connectorConfigUpdates.clear();
// We can't start/stop while locked since starting connectors can cause task updates that will
// require writing configs, which in turn make callbacks into this class from another thread that
// require acquiring a lock. This leads to deadlock. Instead, just copy the info we need and process
// the updates after unlocking.
connectorConfigUpdatesCopy = connectorConfigUpdates;
connectorConfigUpdates = new HashSet<>();
}
}
}
if (connectorConfigUpdatesCopy != null) {
// If we only have connector config updates, we can just bounce the updated connectors that are
// currently assigned to this worker.
Set<String> localConnectors = assignment == null ? Collections.<String>emptySet() : new HashSet<>(assignment.connectors());
for (String connectorName : connectorConfigUpdatesCopy) {
if (!localConnectors.contains(connectorName))
continue;
boolean remains = configState.connectors().contains(connectorName);
log.info("Handling connector-only config update by {} connector {}",
remains ? "restarting" : "stopping", connectorName);
worker.stopConnector(connectorName);
// The update may be a deletion, so verify we actually need to restart the connector
if (remains)
startConnector(connectorName);
}
}
// Let the group take any actions it needs to
try {
@ -272,69 +295,152 @@ public class DistributedHerder implements Herder, Runnable { @@ -272,69 +295,152 @@ public class DistributedHerder implements Herder, Runnable {
}
@Override
public synchronized void addConnector(final Map<String, String> connectorProps,
final Callback<String> callback) {
final ConnectorConfig connConfig;
final String connName;
try {
connConfig = new ConnectorConfig(connectorProps);
connName = connConfig.getString(ConnectorConfig.NAME_CONFIG);
} catch (Throwable t) {
if (callback != null)
callback.onCompletion(t, null);
return;
}
log.debug("Submitting connector config {}", connName);
public synchronized void connectors(final Callback<Collection<String>> callback) {
log.trace("Submitting connector listing request");
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public Void call() throws Exception {
if (!isLeader())
throw new NotLeaderException("Only the leader can add connectors.");
if (!checkConfigSynced(callback))
return null;
log.debug("Submitting connector config {}", connName);
configStorage.putConnectorConfig(connName, connectorProps);
callback.onCompletion(null, configState.connectors());
return null;
}
}
));
member.wakeup();
}
@Override
public synchronized void connectorInfo(final String connName, final Callback<ConnectorInfo> callback) {
log.trace("Submitting connector info request {}", connName);
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public Void call() throws Exception {
if (!checkConfigSynced(callback))
return null;
if (!configState.connectors().contains(connName)) {
callback.onCompletion(new NotFoundException("Connector " + connName + " not found"), null);
} else {
callback.onCompletion(null, new ConnectorInfo(connName, configState.connectorConfig(connName), configState.tasks(connName)));
}
return null;
}
},
new Callback<Void>() {
}
));
member.wakeup();
}
@Override
public void connectorConfig(String connName, final Callback<Map<String, String>> callback) {
// Subset of connectorInfo, so piggy back on that implementation
log.trace("Submitting connector config read request {}", connName);
connectorInfo(connName, new Callback<ConnectorInfo>() {
@Override
public void onCompletion(Throwable error, ConnectorInfo result) {
if (error != null)
callback.onCompletion(error, null);
else
callback.onCompletion(null, result.config());
}
});
}
@Override
public void putConnectorConfig(final String connName, Map<String, String> config, final boolean allowReplace,
final Callback<Created<ConnectorInfo>> callback) {
final Map<String, String> connConfig;
if (config == null) {
connConfig = null;
} else if (!config.containsKey(ConnectorConfig.NAME_CONFIG)) {
connConfig = new HashMap<>(config);
connConfig.put(ConnectorConfig.NAME_CONFIG, connName);
} else {
connConfig = config;
}
log.trace("Submitting connector config write request {}", connName);
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public void onCompletion(Throwable error, Void result) {
if (callback == null) return;
public Void call() throws Exception {
log.trace("Handling connector config request {}", connName);
if (!isLeader()) {
callback.onCompletion(new NotLeaderException("Only the leader can set connector configs.", leaderUrl()), null);
return null;
}
boolean exists = configState.connectors().contains(connName);
if (!allowReplace && exists) {
callback.onCompletion(new AlreadyExistsException("Connector " + connName + " already exists"), null);
return null;
}
if (connConfig == null && !exists) {
callback.onCompletion(new NotFoundException("Connector " + connName + " not found"), null);
return null;
}
log.trace("Submitting connector config {} {} {}", connName, allowReplace, configState.connectors());
configStorage.putConnectorConfig(connName, connConfig);
boolean created = !exists && connConfig != null;
// Note that we use the updated connector config despite the fact that we don't have an updated
// snapshot yet. The existing task info should still be accurate.
ConnectorInfo info = connConfig == null ? null :
new ConnectorInfo(connName, connConfig, configState.tasks(connName));
callback.onCompletion(null, new Created<>(created, info));
if (error != null)
callback.onCompletion(error, null);
else
callback.onCompletion(null, connName);
return null;
}
}));
member.wakeup();
}
@Override
public synchronized void deleteConnector(final String connName, final Callback<Void> callback) {
log.debug("Submitting connector config deletion {}", connName);
public synchronized void requestTaskReconfiguration(final String connName) {
log.trace("Submitting connector task reconfiguration request {}", connName);
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public Void call() throws Exception {
if (!isLeader())
throw new NotLeaderException("Only the leader can delete connectors.");
log.debug("Submitting null connector config {}", connName);
configStorage.putConnectorConfig(connName, null);
reconfigureConnector(connName);
return null;
}
},
new Callback<Void>() {
}
));
member.wakeup();
}
@Override
public synchronized void taskConfigs(final String connName, final Callback<List<TaskInfo>> callback) {
log.trace("Submitting get task configuration request {}", connName);
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public void onCompletion(Throwable error, Void result) {
if (callback != null)
callback.onCompletion(error, null);
public Void call() throws Exception {
if (!checkConfigSynced(callback))
return null;
if (!configState.connectors().contains(connName)) {
callback.onCompletion(new NotFoundException("Connector " + connName + " not found"), null);
} else {
List<TaskInfo> result = new ArrayList<>();
for (int i = 0; i < configState.taskCount(connName); i++) {
ConnectorTaskId id = new ConnectorTaskId(connName, i);
result.add(new TaskInfo(id, configState.taskConfig(id)));
}
callback.onCompletion(null, result);
}
return null;
}
}
));
@ -342,12 +448,21 @@ public class DistributedHerder implements Herder, Runnable { @@ -342,12 +448,21 @@ public class DistributedHerder implements Herder, Runnable {
}
@Override
public synchronized void requestTaskReconfiguration(final String connName) {
public synchronized void putTaskConfigs(final String connName, final List<Map<String, String>> configs, final Callback<Void> callback) {
log.trace("Submitting put task configuration request {}", connName);
requests.add(new HerderRequest(
new Callable<Void>() {
@Override
public Void call() throws Exception {
reconfigureConnector(connName);
if (!isLeader())
callback.onCompletion(new NotLeaderException("Only the leader may write task configurations.", leaderUrl()), null);
else if (!configState.connectors().contains(connName))
callback.onCompletion(new NotFoundException("Connector " + connName + " not found"), null);
else {
configStorage.putTaskConfigs(taskConfigListAsMap(connName, configs));
callback.onCompletion(null, null);
}
return null;
}
}
@ -356,10 +471,20 @@ public class DistributedHerder implements Herder, Runnable { @@ -356,10 +471,20 @@ public class DistributedHerder implements Herder, Runnable {
}
// Should only be called from work thread, so synchronization should not be needed
private boolean isLeader() {
return assignment != null && member.memberId().equals(assignment.leader());
}
/**
* Get the URL for the leader's REST interface, or null if we do not have the leader's URL yet.
*/
private String leaderUrl() {
if (assignment == null)
return null;
return assignment.leaderUrl();
}
/**
* Handle post-assignment operations, either trying to resolve issues that kept assignment from completing, getting
* this node into sync and its work started. Since
@ -370,8 +495,6 @@ public class DistributedHerder implements Herder, Runnable { @@ -370,8 +495,6 @@ public class DistributedHerder implements Herder, Runnable {
if (this.rebalanceResolved)
return true;
rebalanceResolved = true;
// We need to handle a variety of cases after a rebalance:
// 1. Assignment failed
// 1a. We are the leader for the round. We will be leader again if we rejoin now, so we need to catch up before
@ -430,6 +553,10 @@ public class DistributedHerder implements Herder, Runnable { @@ -430,6 +553,10 @@ public class DistributedHerder implements Herder, Runnable {
startWork();
// We only mark this as resolved once we've actually started work, which allows us to correctly track whether
// what work is currently active and running. If we bail early, the main tick loop + having requested rejoin
// guarantees we'll attempt to rejoin before executing this method again.
rebalanceResolved = true;
return true;
}
@ -483,6 +610,7 @@ public class DistributedHerder implements Herder, Runnable { @@ -483,6 +610,7 @@ public class DistributedHerder implements Herder, Runnable {
"configuration. This task will not execute until reconfigured.", e);
}
}
log.info("Finished starting connectors and tasks");
}
// Helper for starting a connector with the given name, which will extract & parse the config, generate connector
@ -511,27 +639,49 @@ public class DistributedHerder implements Herder, Runnable { @@ -511,27 +639,49 @@ public class DistributedHerder implements Herder, Runnable {
if (SinkConnector.class.isAssignableFrom(connConfig.getClass(ConnectorConfig.CONNECTOR_CLASS_CONFIG)))
sinkTopics = connConfig.getList(ConnectorConfig.TOPICS_CONFIG);
Map<ConnectorTaskId, Map<String, String>> taskProps
= worker.reconfigureConnectorTasks(connName, connConfig.getInt(ConnectorConfig.TASKS_MAX_CONFIG), sinkTopics);
List<Map<String, String>> taskProps
= worker.connectorTaskConfigs(connName, connConfig.getInt(ConnectorConfig.TASKS_MAX_CONFIG), sinkTopics);
boolean changed = false;
int currentNumTasks = configState.taskCount(connName);
if (taskProps.size() != currentNumTasks) {
log.debug("Change in connector task count from {} to {}, writing updated task configurations", currentNumTasks, taskProps.size());
changed = true;
} else {
for (Map.Entry<ConnectorTaskId, Map<String, String>> taskConfig : taskProps.entrySet()) {
if (!taskConfig.getValue().equals(configState.taskConfig(taskConfig.getKey()))) {
int index = 0;
for (Map<String, String> taskConfig : taskProps) {
if (!taskConfig.equals(configState.taskConfig(new ConnectorTaskId(connName, index)))) {
log.debug("Change in task configurations, writing updated task configurations");
changed = true;
break;
}
index++;
}
}
if (changed) {
// FIXME: Configs should only be written by the leader to avoid conflicts due to zombies. However, until the
// REST API is available to forward this request, we need to do this on the worker that generates the config
configStorage.putTaskConfigs(taskProps);
if (isLeader()) {
configStorage.putTaskConfigs(taskConfigListAsMap(connName, taskProps));
} else {
try {
String reconfigUrl = RestServer.urlJoin(leaderUrl(), "/connectors/" + connName + "/tasks");
RestServer.httpRequest(reconfigUrl, "POST", taskProps, null);
} catch (CopycatException e) {
log.error("Request to leader to reconfigure connector tasks failed", e);
}
}
}
}
// Common handling for requests that get config data. Checks if we are in sync with the current config, which allows
// us to answer requests directly. If we are not, handles invoking the callback with the appropriate error.
private boolean checkConfigSynced(Callback<?> callback) {
if (assignment == null || configState.offset() != assignment.offset()) {
if (!isLeader())
callback.onCompletion(new NotLeaderException("Cannot get config data because config is not in sync and this is not the leader", leaderUrl()), null);
else
callback.onCompletion(new CopycatException("Cannot get config data because this is the leader node, but it does not have the most up to date configs"), null);
return false;
}
return true;
}
@ -572,7 +722,7 @@ public class DistributedHerder implements Herder, Runnable { @@ -572,7 +722,7 @@ public class DistributedHerder implements Herder, Runnable {
return new Callback<String>() {
@Override
public void onCompletion(Throwable error, String connector) {
log.debug("Connector {} config updated", connector);
log.info("Connector {} config updated", connector);
// Stage the update and wake up the work thread. Connector config *changes* only need the one connector
// to be bounced. However, this callback may also indicate a connector *addition*, which does require
// a rebalance, so we need to be careful about what operation we request.
@ -588,7 +738,7 @@ public class DistributedHerder implements Herder, Runnable { @@ -588,7 +738,7 @@ public class DistributedHerder implements Herder, Runnable {
return new Callback<List<ConnectorTaskId>>() {
@Override
public void onCompletion(Throwable error, List<ConnectorTaskId> tasks) {
log.debug("Tasks {} configs updated", tasks);
log.info("Tasks {} configs updated", tasks);
// Stage the update and wake up the work thread. No need to record the set of tasks here because task reconfigs
// always need a rebalance to ensure offsets get committed.
// TODO: As an optimization, some task config updates could avoid a rebalance. In particular, single-task
@ -612,8 +762,10 @@ public class DistributedHerder implements Herder, Runnable { @@ -612,8 +762,10 @@ public class DistributedHerder implements Herder, Runnable {
// group membership actions (e.g., we may need to explicitly leave the group if we cannot handle the
// assigned tasks).
log.info("Joined group and got assignment: {}", assignment);
DistributedHerder.this.assignment = assignment;
rebalanceResolved = false;
synchronized (DistributedHerder.this) {
DistributedHerder.this.assignment = assignment;
rebalanceResolved = false;
}
// We *must* interrupt any poll() call since this could occur when the poll starts, and we might then
// sleep in the poll() for a long time. Forcing a wakeup ensures we'll get to process this event in the
// main thread.
@ -627,21 +779,40 @@ public class DistributedHerder implements Herder, Runnable { @@ -627,21 +779,40 @@ public class DistributedHerder implements Herder, Runnable {
// Note that since we don't reset the assignment, we we don't revoke leadership here. During a rebalance,
// it is still important to have a leader that can write configs, offsets, etc.
// TODO: Parallelize this. We should be able to request all connectors and tasks to stop, then wait on all of
// them to finish
// TODO: Technically we don't have to stop connectors at all until we know they've really been removed from
// this worker. Instead, we can let them continue to run but buffer any update requests (which should be
// rare anyway). This would avoid a steady stream of start/stop, which probably also includes lots of
// unnecessary repeated connections to the source/sink system.
for (String connectorName : connectors)
worker.stopConnector(connectorName);
// TODO: We need to at least commit task offsets, but if we could commit offsets & pause them instead of
// stopping them then state could continue to be reused when the task remains on this worker. For example,
// this would avoid having to close a connection and then reopen it when the task is assigned back to this
// worker again.
for (ConnectorTaskId taskId : tasks)
worker.stopTask(taskId);
if (rebalanceResolved) {
// TODO: Parallelize this. We should be able to request all connectors and tasks to stop, then wait on all of
// them to finish
// TODO: Technically we don't have to stop connectors at all until we know they've really been removed from
// this worker. Instead, we can let them continue to run but buffer any update requests (which should be
// rare anyway). This would avoid a steady stream of start/stop, which probably also includes lots of
// unnecessary repeated connections to the source/sink system.
for (String connectorName : connectors)
worker.stopConnector(connectorName);
// TODO: We need to at least commit task offsets, but if we could commit offsets & pause them instead of
// stopping them then state could continue to be reused when the task remains on this worker. For example,
// this would avoid having to close a connection and then reopen it when the task is assigned back to this
// worker again.
for (ConnectorTaskId taskId : tasks)
worker.stopTask(taskId);
log.info("Finished stopping tasks in preparation for rebalance");
} else {
log.info("Wasn't unable to resume work after last rebalance, can skip stopping connectors and tasks");
}
}
};
}
private static Map<ConnectorTaskId, Map<String, String>> taskConfigListAsMap(String connName, List<Map<String, String>> configs) {
int index = 0;
Map<ConnectorTaskId, Map<String, String>> result = new HashMap<>();
for (Map<String, String> taskConfigMap : configs) {
ConnectorTaskId taskId = new ConnectorTaskId(connName, index);
result.put(taskId, taskConfigMap);
index++;
}
return result;
}
}

19
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/NotLeaderException.java

@ -24,15 +24,24 @@ import org.apache.kafka.copycat.errors.CopycatException; @@ -24,15 +24,24 @@ import org.apache.kafka.copycat.errors.CopycatException;
* the leader.
*/
public class NotLeaderException extends CopycatException {
public NotLeaderException(String s) {
super(s);
private final String leaderUrl;
public NotLeaderException(String msg, String leaderUrl) {
super(msg);
this.leaderUrl = leaderUrl;
}
public NotLeaderException(String s, Throwable throwable) {
super(s, throwable);
public NotLeaderException(String msg, String leaderUrl, Throwable throwable) {
super(msg, throwable);
this.leaderUrl = leaderUrl;
}
public NotLeaderException(Throwable throwable) {
public NotLeaderException(String leaderUrl, Throwable throwable) {
super(throwable);
this.leaderUrl = leaderUrl;
}
public String leaderUrl() {
return leaderUrl;
}
}

23
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/WorkerCoordinator.java

@ -48,6 +48,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -48,6 +48,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
// Currently Copycat doesn't support multiple task assignment strategies, so we currently just fill in a default value
public static final String DEFAULT_SUBPROTOCOL = "default";
private final String restUrl;
private final KafkaConfigStorage configStorage;
private CopycatProtocol.Assignment assignmentSnapshot;
private final CopycatWorkerCoordinatorMetrics sensors;
@ -69,6 +70,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -69,6 +70,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
Time time,
long requestTimeoutMs,
long retryBackoffMs,
String restUrl,
KafkaConfigStorage configStorage,
WorkerRebalanceListener listener) {
super(client,
@ -81,6 +83,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -81,6 +83,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
time,
requestTimeoutMs,
retryBackoffMs);
this.restUrl = restUrl;
this.configStorage = configStorage;
this.assignmentSnapshot = null;
this.sensors = new CopycatWorkerCoordinatorMetrics(metrics, metricGrpPrefix, metricTags);
@ -101,8 +104,8 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -101,8 +104,8 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
public LinkedHashMap<String, ByteBuffer> metadata() {
LinkedHashMap<String, ByteBuffer> metadata = new LinkedHashMap<>();
configSnapshot = configStorage.snapshot();
CopycatProtocol.ConfigState configState = new CopycatProtocol.ConfigState(configSnapshot.offset());
metadata.put(DEFAULT_SUBPROTOCOL, CopycatProtocol.serializeMetadata(configState));
CopycatProtocol.WorkerState workerState = new CopycatProtocol.WorkerState(restUrl, configSnapshot.offset());
metadata.put(DEFAULT_SUBPROTOCOL, CopycatProtocol.serializeMetadata(workerState));
return metadata;
}
@ -121,7 +124,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -121,7 +124,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
protected Map<String, ByteBuffer> performAssignment(String leaderId, String protocol, Map<String, ByteBuffer> allMemberMetadata) {
log.debug("Performing task assignment");
Map<String, CopycatProtocol.ConfigState> allConfigs = new HashMap<>();
Map<String, CopycatProtocol.WorkerState> allConfigs = new HashMap<>();
for (Map.Entry<String, ByteBuffer> entry : allMemberMetadata.entrySet())
allConfigs.put(entry.getKey(), CopycatProtocol.deserializeMetadata(entry.getValue()));
@ -129,16 +132,17 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -129,16 +132,17 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
Long leaderOffset = ensureLeaderConfig(maxOffset);
if (leaderOffset == null)
return fillAssignmentsAndSerialize(allConfigs.keySet(), CopycatProtocol.Assignment.CONFIG_MISMATCH,
leaderId, maxOffset, new HashMap<String, List<String>>(), new HashMap<String, List<ConnectorTaskId>>());
leaderId, allConfigs.get(leaderId).url(), maxOffset,
new HashMap<String, List<String>>(), new HashMap<String, List<ConnectorTaskId>>());
return performTaskAssignment(leaderId, leaderOffset, allConfigs);
}
private long findMaxMemberConfigOffset(Map<String, CopycatProtocol.ConfigState> allConfigs) {
private long findMaxMemberConfigOffset(Map<String, CopycatProtocol.WorkerState> allConfigs) {
// The new config offset is the maximum seen by any member. We always perform assignment using this offset,
// even if some members have fallen behind. The config offset used to generate the assignment is included in
// the response so members that have fallen behind will not use the assignment until they have caught up.
Long maxOffset = null;
for (Map.Entry<String, CopycatProtocol.ConfigState> stateEntry : allConfigs.entrySet()) {
for (Map.Entry<String, CopycatProtocol.WorkerState> stateEntry : allConfigs.entrySet()) {
long memberRootOffset = stateEntry.getValue().offset();
if (maxOffset == null)
maxOffset = memberRootOffset;
@ -171,7 +175,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -171,7 +175,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
return maxOffset;
}
private Map<String, ByteBuffer> performTaskAssignment(String leaderId, long maxOffset, Map<String, CopycatProtocol.ConfigState> allConfigs) {
private Map<String, ByteBuffer> performTaskAssignment(String leaderId, long maxOffset, Map<String, CopycatProtocol.WorkerState> allConfigs) {
Map<String, List<String>> connectorAssignments = new HashMap<>();
Map<String, List<ConnectorTaskId>> taskAssignments = new HashMap<>();
@ -200,12 +204,13 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -200,12 +204,13 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
}
return fillAssignmentsAndSerialize(allConfigs.keySet(), CopycatProtocol.Assignment.NO_ERROR,
leaderId, maxOffset, connectorAssignments, taskAssignments);
leaderId, allConfigs.get(leaderId).url(), maxOffset, connectorAssignments, taskAssignments);
}
private Map<String, ByteBuffer> fillAssignmentsAndSerialize(Collection<String> members,
short error,
String leaderId,
String leaderUrl,
long maxOffset,
Map<String, List<String>> connectorAssignments,
Map<String, List<ConnectorTaskId>> taskAssignments) {
@ -218,7 +223,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos @@ -218,7 +223,7 @@ public final class WorkerCoordinator extends AbstractCoordinator implements Clos
List<ConnectorTaskId> tasks = taskAssignments.get(member);
if (tasks == null)
tasks = Collections.emptyList();
CopycatProtocol.Assignment assignment = new CopycatProtocol.Assignment(error, leaderId, maxOffset, connectors, tasks);
CopycatProtocol.Assignment assignment = new CopycatProtocol.Assignment(error, leaderId, leaderUrl, maxOffset, connectors, tasks);
log.debug("Assignment: {} -> {}", member, assignment);
groupAssignment.put(member, CopycatProtocol.serializeAssignment(assignment));
}

9
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/distributed/WorkerGroupMember.java

@ -68,7 +68,7 @@ public class WorkerGroupMember { @@ -68,7 +68,7 @@ public class WorkerGroupMember {
private boolean stopped = false;
public WorkerGroupMember(DistributedHerderConfig config, KafkaConfigStorage configStorage, WorkerRebalanceListener listener) {
public WorkerGroupMember(DistributedConfig config, String restUrl, KafkaConfigStorage configStorage, WorkerRebalanceListener listener) {
try {
this.time = new SystemTime();
@ -98,15 +98,16 @@ public class WorkerGroupMember { @@ -98,15 +98,16 @@ public class WorkerGroupMember {
config.getInt(CommonClientConfigs.REQUEST_TIMEOUT_MS_CONFIG), time);
this.client = new ConsumerNetworkClient(netClient, metadata, time, retryBackoffMs);
this.coordinator = new WorkerCoordinator(this.client,
config.getString(DistributedHerderConfig.GROUP_ID_CONFIG),
config.getInt(DistributedHerderConfig.SESSION_TIMEOUT_MS_CONFIG),
config.getInt(DistributedHerderConfig.HEARTBEAT_INTERVAL_MS_CONFIG),
config.getString(DistributedConfig.GROUP_ID_CONFIG),
config.getInt(DistributedConfig.SESSION_TIMEOUT_MS_CONFIG),
config.getInt(DistributedConfig.HEARTBEAT_INTERVAL_MS_CONFIG),
metrics,
metricGrpPrefix,
metricsTags,
this.time,
config.getInt(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG),
retryBackoffMs,
restUrl,
configStorage,
listener);

258
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/RestServer.java

@ -0,0 +1,258 @@ @@ -0,0 +1,258 @@
/**
* 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.copycat.runtime.rest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.WorkerConfig;
import org.apache.kafka.copycat.runtime.rest.entities.ErrorMessage;
import org.apache.kafka.copycat.runtime.rest.errors.CopycatExceptionMapper;
import org.apache.kafka.copycat.runtime.rest.errors.CopycatRestException;
import org.apache.kafka.copycat.runtime.rest.resources.ConnectorsResource;
import org.apache.kafka.copycat.runtime.rest.resources.RootResource;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.Slf4jRequestLog;
import org.eclipse.jetty.server.handler.DefaultHandler;
import org.eclipse.jetty.server.handler.HandlerCollection;
import org.eclipse.jetty.server.handler.RequestLogHandler;
import org.eclipse.jetty.server.handler.StatisticsHandler;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.servlet.ServletContainer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;
import java.util.Map;
/**
* Embedded server for the REST API that provides the control plane for Copycat workers.
*/
public class RestServer {
private static final Logger log = LoggerFactory.getLogger(RestServer.class);
private static final long GRACEFUL_SHUTDOWN_TIMEOUT_MS = 60 * 1000;
private static final ObjectMapper JSON_SERDE = new ObjectMapper();
private final WorkerConfig config;
private Herder herder;
private Server jettyServer;
/**
* Create a REST server for this herder using the specified configs.
*/
public RestServer(WorkerConfig config) {
this.config = config;
// To make the advertised port available immediately, we need to do some configuration here
String hostname = config.getString(WorkerConfig.REST_HOST_NAME_CONFIG);
Integer port = config.getInt(WorkerConfig.REST_PORT_CONFIG);
jettyServer = new Server();
ServerConnector connector = new ServerConnector(jettyServer);
if (hostname != null && !hostname.isEmpty())
connector.setHost(hostname);
connector.setPort(port);
jettyServer.setConnectors(new Connector[]{connector});
}
public void start(Herder herder) {
log.info("Starting REST server");
this.herder = herder;
ResourceConfig resourceConfig = new ResourceConfig();
resourceConfig.register(new JacksonJsonProvider());
resourceConfig.register(RootResource.class);
resourceConfig.register(new ConnectorsResource(herder));
resourceConfig.register(CopycatExceptionMapper.class);
ServletContainer servletContainer = new ServletContainer(resourceConfig);
ServletHolder servletHolder = new ServletHolder(servletContainer);
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/");
context.addServlet(servletHolder, "/*");
RequestLogHandler requestLogHandler = new RequestLogHandler();
Slf4jRequestLog requestLog = new Slf4jRequestLog();
requestLog.setLoggerName(RestServer.class.getCanonicalName());
requestLog.setLogLatency(true);
requestLogHandler.setRequestLog(requestLog);
HandlerCollection handlers = new HandlerCollection();
handlers.setHandlers(new Handler[]{context, new DefaultHandler(), requestLogHandler});
/* Needed for graceful shutdown as per `setStopTimeout` documentation */
StatisticsHandler statsHandler = new StatisticsHandler();
statsHandler.setHandler(handlers);
jettyServer.setHandler(statsHandler);
jettyServer.setStopTimeout(GRACEFUL_SHUTDOWN_TIMEOUT_MS);
jettyServer.setStopAtShutdown(true);
try {
jettyServer.start();
} catch (Exception e) {
throw new CopycatException("Unable to start REST server", e);
}
log.info("REST server listening at " + jettyServer.getURI() + ", advertising URL " + advertisedUrl());
}
public void stop() {
try {
jettyServer.stop();
jettyServer.join();
} catch (Exception e) {
throw new CopycatException("Unable to stop REST server", e);
} finally {
jettyServer.destroy();
}
}
/**
* Get the URL to advertise to other workers and clients. This uses the default connector from the embedded Jetty
* server, unless overrides for advertised hostname and/or port are provided via configs.
*/
public String advertisedUrl() {
UriBuilder builder = UriBuilder.fromUri(jettyServer.getURI());
String advertisedHostname = config.getString(WorkerConfig.REST_ADVERTISED_HOST_NAME_CONFIG);
if (advertisedHostname != null && !advertisedHostname.isEmpty())
builder.host(advertisedHostname);
Integer advertisedPort = config.getInt(WorkerConfig.REST_ADVERTISED_PORT_CONFIG);
if (advertisedPort != null)
builder.port(advertisedPort);
else
builder.port(config.getInt(WorkerConfig.REST_PORT_CONFIG));
return builder.build().toString();
}
/**
* @param url HTTP connection will be established with this url.
* @param method HTTP method ("GET", "POST", "PUT", etc.)
* @param requestBodyData Object to serialize as JSON and send in the request body.
* @param responseFormat Expected format of the response to the HTTP request.
* @param <T> The type of the deserialized response to the HTTP request.
* @return The deserialized response to the HTTP request, or null if no data is expected.
*/
public static <T> HttpResponse<T> httpRequest(String url, String method, Object requestBodyData,
TypeReference<T> responseFormat) {
HttpURLConnection connection = null;
try {
String serializedBody = requestBodyData == null ? null : JSON_SERDE.writeValueAsString(requestBodyData);
log.debug("Sending {} with input {} to {}", method, serializedBody, url);
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method);
connection.setRequestProperty("User-Agent", "kafka-copycat");
connection.setRequestProperty("Accept", "application/json");
// connection.getResponseCode() implicitly calls getInputStream, so always set to true.
// On the other hand, leaving this out breaks nothing.
connection.setDoInput(true);
connection.setUseCaches(false);
if (requestBodyData != null) {
connection.setRequestProperty("Content-Type", "application/json");
connection.setDoOutput(true);
OutputStream os = connection.getOutputStream();
os.write(serializedBody.getBytes());
os.flush();
os.close();
}
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_NO_CONTENT) {
return new HttpResponse<>(responseCode, connection.getHeaderFields(), null);
} else if (responseCode >= 400) {
InputStream es = connection.getErrorStream();
ErrorMessage errorMessage = JSON_SERDE.readValue(es, ErrorMessage.class);
es.close();
throw new CopycatRestException(responseCode, errorMessage.errorCode(), errorMessage.message());
} else if (responseCode >= 200 && responseCode < 300) {
InputStream is = connection.getInputStream();
T result = JSON_SERDE.readValue(is, responseFormat);
is.close();
return new HttpResponse<>(responseCode, connection.getHeaderFields(), result);
} else {
throw new CopycatRestException(Response.Status.INTERNAL_SERVER_ERROR,
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
"Unexpected status code when handling forwarded request: " + responseCode);
}
} catch (IOException e) {
log.error("IO error forwarding REST request: ", e);
throw new CopycatRestException(Response.Status.INTERNAL_SERVER_ERROR, "IO Error trying to forward REST request: " + e.getMessage(), e);
} finally {
if (connection != null)
connection.disconnect();
}
}
public static class HttpResponse<T> {
private int status;
private Map<String, List<String>> headers;
private T body;
public HttpResponse(int status, Map<String, List<String>> headers, T body) {
this.status = status;
this.headers = headers;
this.body = body;
}
public int status() {
return status;
}
public Map<String, List<String>> headers() {
return headers;
}
public T body() {
return body;
}
}
public static String urlJoin(String base, String path) {
if (base.endsWith("/") && path.startsWith("/"))
return base + path.substring(1);
else
return base + path;
}
}

81
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ConnectorInfo.java

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
/**
* 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.copycat.runtime.rest.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class ConnectorInfo {
private final String name;
private final Map<String, String> config;
private final List<ConnectorTaskId> tasks;
@JsonCreator
public ConnectorInfo(@JsonProperty("name") String name, @JsonProperty("config") Map<String, String> config,
@JsonProperty("tasks") List<ConnectorTaskId> tasks) {
this.name = name;
this.config = config;
this.tasks = tasks;
}
@JsonProperty
public String name() {
return name;
}
@JsonProperty
public Map<String, String> config() {
return config;
}
@JsonProperty
public List<ConnectorTaskId> tasks() {
return tasks;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ConnectorInfo that = (ConnectorInfo) o;
return Objects.equals(name, that.name) &&
Objects.equals(config, that.config) &&
Objects.equals(tasks, that.tasks);
}
@Override
public int hashCode() {
return Objects.hash(name, config, tasks);
}
private static List<ConnectorTaskId> jsonTasks(Collection<org.apache.kafka.copycat.util.ConnectorTaskId> tasks) {
List<ConnectorTaskId> jsonTasks = new ArrayList<>();
for (ConnectorTaskId task : tasks)
jsonTasks.add(task);
return jsonTasks;
}
}

59
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/CreateConnectorRequest.java

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
/**
* 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.copycat.runtime.rest.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
import java.util.Objects;
public class CreateConnectorRequest {
private final String name;
private final Map<String, String> config;
@JsonCreator
public CreateConnectorRequest(@JsonProperty("name") String name, @JsonProperty("config") Map<String, String> config) {
this.name = name;
this.config = config;
}
@JsonProperty
public String name() {
return name;
}
@JsonProperty
public Map<String, String> config() {
return config;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CreateConnectorRequest that = (CreateConnectorRequest) o;
return Objects.equals(name, that.name) &&
Objects.equals(config, that.config);
}
@Override
public int hashCode() {
return Objects.hash(name, config);
}
}

63
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ErrorMessage.java

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
/**
* 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.copycat.runtime.rest.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
/**
* Standard error format for all REST API failures. These are generated automatically by
* {@link org.apache.kafka.copycat.runtime.rest.errors.CopycatExceptionMapper} in response to uncaught
* {@link org.apache.kafka.copycat.errors.CopycatException}s.
*/
public class ErrorMessage {
private final int errorCode;
private final String message;
@JsonCreator
public ErrorMessage(@JsonProperty("error_code") int errorCode, @JsonProperty("message") String message) {
this.errorCode = errorCode;
this.message = message;
}
@JsonProperty("error_code")
public int errorCode() {
return errorCode;
}
@JsonProperty
public String message() {
return message;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ErrorMessage that = (ErrorMessage) o;
return Objects.equals(errorCode, that.errorCode) &&
Objects.equals(message, that.message);
}
@Override
public int hashCode() {
return Objects.hash(errorCode, message);
}
}

41
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/ServerInfo.java

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
/**
* 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.copycat.runtime.rest.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.kafka.common.utils.AppInfoParser;
public class ServerInfo {
private String version;
private String commit;
public ServerInfo() {
version = AppInfoParser.getVersion();
commit = AppInfoParser.getCommitId();
}
@JsonProperty
public String version() {
return version;
}
@JsonProperty
public String commit() {
return commit;
}
}

58
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/entities/TaskInfo.java

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
/**
* 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.copycat.runtime.rest.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import java.util.Map;
import java.util.Objects;
public class TaskInfo {
private final ConnectorTaskId id;
private final Map<String, String> config;
public TaskInfo(ConnectorTaskId id, Map<String, String> config) {
this.id = id;
this.config = config;
}
@JsonProperty
public ConnectorTaskId id() {
return id;
}
@JsonProperty
public Map<String, String> config() {
return config;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TaskInfo taskInfo = (TaskInfo) o;
return Objects.equals(id, taskInfo.id) &&
Objects.equals(config, taskInfo.config);
}
@Override
public int hashCode() {
return Objects.hash(id, config);
}
}

60
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/errors/CopycatExceptionMapper.java

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
/**
* 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.copycat.runtime.rest.errors;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.errors.NotFoundException;
import org.apache.kafka.copycat.runtime.rest.entities.ErrorMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
public class CopycatExceptionMapper implements ExceptionMapper<CopycatException> {
private static final Logger log = LoggerFactory.getLogger(CopycatExceptionMapper.class);
@Override
public Response toResponse(CopycatException exception) {
log.debug("Uncaught exception in REST call: ", exception);
if (exception instanceof CopycatRestException) {
CopycatRestException restException = (CopycatRestException) exception;
return Response.status(restException.statusCode())
.entity(new ErrorMessage(restException.errorCode(), restException.getMessage()))
.build();
}
if (exception instanceof NotFoundException) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorMessage(Response.Status.NOT_FOUND.getStatusCode(), exception.getMessage()))
.build();
}
if (exception instanceof AlreadyExistsException) {
return Response.status(Response.Status.CONFLICT)
.entity(new ErrorMessage(Response.Status.CONFLICT.getStatusCode(), exception.getMessage()))
.build();
}
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(new ErrorMessage(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), exception.getMessage()))
.build();
}
}

70
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/errors/CopycatRestException.java

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
/**
* 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.copycat.runtime.rest.errors;
import org.apache.kafka.copycat.errors.CopycatException;
import javax.ws.rs.core.Response;
public class CopycatRestException extends CopycatException {
private final int statusCode;
private final int errorCode;
public CopycatRestException(int statusCode, int errorCode, String message, Throwable t) {
super(message, t);
this.statusCode = statusCode;
this.errorCode = errorCode;
}
public CopycatRestException(Response.Status status, int errorCode, String message, Throwable t) {
this(status.getStatusCode(), errorCode, message, t);
}
public CopycatRestException(int statusCode, int errorCode, String message) {
this(statusCode, errorCode, message, null);
}
public CopycatRestException(Response.Status status, int errorCode, String message) {
this(status, errorCode, message, null);
}
public CopycatRestException(int statusCode, String message, Throwable t) {
this(statusCode, statusCode, message, t);
}
public CopycatRestException(Response.Status status, String message, Throwable t) {
this(status, status.getStatusCode(), message, t);
}
public CopycatRestException(int statusCode, String message) {
this(statusCode, statusCode, message, null);
}
public CopycatRestException(Response.Status status, String message) {
this(status.getStatusCode(), status.getStatusCode(), message, null);
}
public int statusCode() {
return statusCode;
}
public int errorCode() {
return errorCode;
}
}

201
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/resources/ConnectorsResource.java

@ -0,0 +1,201 @@ @@ -0,0 +1,201 @@
/**
* 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.copycat.runtime.rest.resources;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.distributed.NotLeaderException;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.CreateConnectorRequest;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.runtime.rest.errors.CopycatRestException;
import org.apache.kafka.copycat.util.FutureCallback;
import javax.servlet.ServletContext;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.net.URI;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@Path("/connectors")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ConnectorsResource {
// TODO: This should not be so long. However, due to potentially long rebalances that may have to wait a full
// session timeout to complete, during which we cannot serve some requests. Ideally we could reduce this, but
// we need to consider all possible scenarios this could fail. It might be ok to fail with a timeout in rare cases,
// but currently a worker simply leaving the group can take this long as well.
private static final long REQUEST_TIMEOUT_MS = 90 * 1000;
private final Herder herder;
@javax.ws.rs.core.Context
private ServletContext context;
public ConnectorsResource(Herder herder) {
this.herder = herder;
}
@GET
@Path("/")
public Collection<String> listConnectors() throws Throwable {
FutureCallback<Collection<String>> cb = new FutureCallback<>();
herder.connectors(cb);
return completeOrForwardRequest(cb, "/connectors", "GET", null, new TypeReference<Collection<String>>() {
});
}
@POST
@Path("/")
public Response createConnector(final CreateConnectorRequest createRequest) throws Throwable {
String name = createRequest.name();
Map<String, String> configs = createRequest.config();
if (!configs.containsKey(ConnectorConfig.NAME_CONFIG))
configs.put(ConnectorConfig.NAME_CONFIG, name);
FutureCallback<Herder.Created<ConnectorInfo>> cb = new FutureCallback<>();
herder.putConnectorConfig(name, configs, false, cb);
Herder.Created<ConnectorInfo> info = completeOrForwardRequest(cb, "/connectors", "POST", createRequest,
new TypeReference<ConnectorInfo>() { }, new CreatedConnectorInfoTranslator());
return Response.created(URI.create("/connectors/" + name)).entity(info.result()).build();
}
@GET
@Path("/{connector}")
public ConnectorInfo getConnector(final @PathParam("connector") String connector) throws Throwable {
FutureCallback<ConnectorInfo> cb = new FutureCallback<>();
herder.connectorInfo(connector, cb);
return completeOrForwardRequest(cb, "/connectors/" + connector, "GET", null, new TypeReference<ConnectorInfo>() {
});
}
@GET
@Path("/{connector}/config")
public Map<String, String> getConnectorConfig(final @PathParam("connector") String connector) throws Throwable {
FutureCallback<Map<String, String>> cb = new FutureCallback<>();
herder.connectorConfig(connector, cb);
return completeOrForwardRequest(cb, "/connectors/" + connector + "/config", "GET", null, new TypeReference<Map<String, String>>() {
});
}
@PUT
@Path("/{connector}/config")
public Response putConnectorConfig(final @PathParam("connector") String connector,
final Map<String, String> connectorConfig) throws Throwable {
FutureCallback<Herder.Created<ConnectorInfo>> cb = new FutureCallback<>();
herder.putConnectorConfig(connector, connectorConfig, true, cb);
Herder.Created<ConnectorInfo> createdInfo = completeOrForwardRequest(cb, "/connectors/" + connector + "/config",
"PUT", connectorConfig, new TypeReference<ConnectorInfo>() { }, new CreatedConnectorInfoTranslator());
Response.ResponseBuilder response;
if (createdInfo.created())
response = Response.created(URI.create("/connectors/" + connector));
else
response = Response.ok();
return response.entity(createdInfo.result()).build();
}
@GET
@Path("/{connector}/tasks")
public List<TaskInfo> getTaskConfigs(final @PathParam("connector") String connector) throws Throwable {
FutureCallback<List<TaskInfo>> cb = new FutureCallback<>();
herder.taskConfigs(connector, cb);
return completeOrForwardRequest(cb, "/connectors/" + connector + "/tasks", "GET", null, new TypeReference<List<TaskInfo>>() {
});
}
@POST
@Path("/{connector}/tasks")
public void putTaskConfigs(final @PathParam("connector") String connector,
final List<Map<String, String>> taskConfigs) throws Throwable {
FutureCallback<Void> cb = new FutureCallback<>();
herder.putTaskConfigs(connector, taskConfigs, cb);
completeOrForwardRequest(cb, "/connectors/" + connector + "/tasks", "POST", taskConfigs);
}
@DELETE
@Path("/{connector}")
public void destroyConnector(final @PathParam("connector") String connector) throws Throwable {
FutureCallback<Herder.Created<ConnectorInfo>> cb = new FutureCallback<>();
herder.putConnectorConfig(connector, null, true, cb);
completeOrForwardRequest(cb, "/connectors/" + connector, "DELETE", null);
}
// Wait for a FutureCallback to complete. If it succeeds, return the parsed response. If it fails, try to forward the
// request to the leader.
private <T, U> T completeOrForwardRequest(
FutureCallback<T> cb, String path, String method, Object body, TypeReference<U> resultType,
Translator<T, U> translator) throws Throwable {
try {
return cb.get(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (ExecutionException e) {
if (e.getCause() instanceof NotLeaderException) {
NotLeaderException notLeaderError = (NotLeaderException) e.getCause();
return translator.translate(RestServer.httpRequest(RestServer.urlJoin(notLeaderError.leaderUrl(), path), method, body, resultType));
}
throw e.getCause();
} catch (TimeoutException e) {
// This timeout is for the operation itself. None of the timeout error codes are relevant, so internal server
// error is the best option
throw new CopycatRestException(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Request timed out");
} catch (InterruptedException e) {
throw new CopycatRestException(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Request interrupted");
}
}
private <T> T completeOrForwardRequest(FutureCallback<T> cb, String path, String method, Object body, TypeReference<T> resultType) throws Throwable {
return completeOrForwardRequest(cb, path, method, body, resultType, new IdentityTranslator<T>());
}
private <T> T completeOrForwardRequest(FutureCallback<T> cb, String path, String method, Object body) throws Throwable {
return completeOrForwardRequest(cb, path, method, body, null, new IdentityTranslator<T>());
}
private interface Translator<T, U> {
T translate(RestServer.HttpResponse<U> response);
}
private class IdentityTranslator<T> implements Translator<T, T> {
@Override
public T translate(RestServer.HttpResponse<T> response) {
return response.body();
}
}
private class CreatedConnectorInfoTranslator implements Translator<Herder.Created<ConnectorInfo>, ConnectorInfo> {
@Override
public Herder.Created<ConnectorInfo> translate(RestServer.HttpResponse<ConnectorInfo> response) {
boolean created = response.status() == 201;
return new Herder.Created<>(created, response.body());
}
}
}

36
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/rest/resources/RootResource.java

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
/**
* 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.copycat.runtime.rest.resources;
import org.apache.kafka.copycat.runtime.rest.entities.ServerInfo;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public class RootResource {
@GET
@Path("/")
public ServerInfo serverInfo() {
return new ServerInfo();
}
}

35
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/standalone/StandaloneConfig.java

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
/**
* 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.copycat.runtime.standalone;
import org.apache.kafka.common.config.ConfigDef;
import org.apache.kafka.copycat.runtime.WorkerConfig;
import java.util.Properties;
public class StandaloneConfig extends WorkerConfig {
private static final ConfigDef CONFIG;
static {
CONFIG = baseConfigDef();
}
public StandaloneConfig(Properties props) {
super(CONFIG, props);
}
}

199
copycat/runtime/src/main/java/org/apache/kafka/copycat/runtime/standalone/StandaloneHerder.java

@ -17,22 +17,27 @@ @@ -17,22 +17,27 @@
package org.apache.kafka.copycat.runtime.standalone;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.errors.NotFoundException;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.HerderConnectorContext;
import org.apache.kafka.copycat.runtime.TaskConfig;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.util.Callback;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
@ -41,7 +46,7 @@ import java.util.Set; @@ -41,7 +46,7 @@ import java.util.Set;
public class StandaloneHerder implements Herder {
private static final Logger log = LoggerFactory.getLogger(StandaloneHerder.class);
private Worker worker;
private final Worker worker;
private HashMap<String, ConnectorState> connectors = new HashMap<>();
public StandaloneHerder(Worker worker) {
@ -59,40 +64,95 @@ public class StandaloneHerder implements Herder { @@ -59,40 +64,95 @@ public class StandaloneHerder implements Herder {
// There's no coordination/hand-off to do here since this is all standalone. Instead, we
// should just clean up the stuff we normally would, i.e. cleanly checkpoint and shutdown all
// the tasks.
for (String connName : new HashSet<>(connectors.keySet()))
stopConnector(connName);
for (String connName : new HashSet<>(connectors.keySet())) {
removeConnectorTasks(connName);
try {
worker.stopConnector(connName);
} catch (CopycatException e) {
log.error("Error shutting down connector {}: ", connName, e);
}
}
connectors.clear();
log.info("Herder stopped");
}
@Override
public synchronized void addConnector(Map<String, String> connectorProps,
Callback<String> callback) {
try {
ConnectorConfig connConfig = new ConnectorConfig(connectorProps);
String connName = connConfig.getString(ConnectorConfig.NAME_CONFIG);
worker.addConnector(connConfig, new HerderConnectorContext(this, connName));
connectors.put(connName, new ConnectorState(connConfig));
if (callback != null)
callback.onCompletion(null, connName);
// This should always be a new job, create jobs from scratch
createConnectorTasks(connName);
} catch (CopycatException e) {
if (callback != null)
callback.onCompletion(e, null);
public synchronized void connectors(Callback<Collection<String>> callback) {
callback.onCompletion(null, new ArrayList<>(connectors.keySet()));
}
@Override
public synchronized void connectorInfo(String connName, Callback<ConnectorInfo> callback) {
ConnectorState state = connectors.get(connName);
if (state == null) {
callback.onCompletion(new NotFoundException("Connector " + connName + " not found"), null);
return;
}
callback.onCompletion(null, createConnectorInfo(state));
}
private ConnectorInfo createConnectorInfo(ConnectorState state) {
if (state == null)
return null;
List<ConnectorTaskId> taskIds = new ArrayList<>();
for (int i = 0; i < state.taskConfigs.size(); i++)
taskIds.add(new ConnectorTaskId(state.name, i));
return new ConnectorInfo(state.name, state.configOriginals, taskIds);
}
@Override
public synchronized void deleteConnector(String connName, Callback<Void> callback) {
public void connectorConfig(String connName, final Callback<Map<String, String>> callback) {
// Subset of connectorInfo, so piggy back on that implementation
connectorInfo(connName, new Callback<ConnectorInfo>() {
@Override
public void onCompletion(Throwable error, ConnectorInfo result) {
if (error != null) {
callback.onCompletion(error, null);
return;
}
callback.onCompletion(null, result.config());
}
});
}
@Override
public synchronized void putConnectorConfig(String connName, final Map<String, String> config,
boolean allowReplace,
final Callback<Created<ConnectorInfo>> callback) {
try {
stopConnector(connName);
if (callback != null)
callback.onCompletion(null, null);
boolean created = false;
if (connectors.containsKey(connName)) {
if (!allowReplace) {
callback.onCompletion(new AlreadyExistsException("Connector " + connName + " already exists"), null);
return;
}
if (config == null) // Deletion, kill tasks as well
removeConnectorTasks(connName);
worker.stopConnector(connName);
if (config == null)
connectors.remove(connName);
} else {
if (config == null) {
// Deletion, must already exist
callback.onCompletion(new NotFoundException("Connector " + connName + " not found", null), null);
return;
}
created = true;
}
if (config != null) {
startConnector(config);
updateConnectorTasks(connName);
}
if (config != null)
callback.onCompletion(null, new Created<>(created, createConnectorInfo(connectors.get(connName))));
else
callback.onCompletion(null, new Created<ConnectorInfo>(false, null));
} catch (CopycatException e) {
if (callback != null)
callback.onCompletion(e, null);
callback.onCompletion(e, null);
}
}
@Override
@ -104,68 +164,109 @@ public class StandaloneHerder implements Herder { @@ -104,68 +164,109 @@ public class StandaloneHerder implements Herder {
updateConnectorTasks(connName);
}
// Stops a connectors tasks, then the connector
private void stopConnector(String connName) {
removeConnectorTasks(connName);
try {
worker.stopConnector(connName);
connectors.remove(connName);
} catch (CopycatException e) {
log.error("Error shutting down connector {}: ", connName, e);
@Override
public synchronized void taskConfigs(String connName, Callback<List<TaskInfo>> callback) {
ConnectorState state = connectors.get(connName);
if (state == null) {
callback.onCompletion(new NotFoundException("Connector " + connName + " not found", null), null);
return;
}
List<TaskInfo> result = new ArrayList<>();
for (int i = 0; i < state.taskConfigs.size(); i++) {
TaskInfo info = new TaskInfo(new ConnectorTaskId(connName, i), state.taskConfigs.get(i));
result.add(info);
}
callback.onCompletion(null, result);
}
private void createConnectorTasks(String connName) {
@Override
public void putTaskConfigs(String connName, List<Map<String, String>> configs, Callback<Void> callback) {
throw new UnsupportedOperationException("Copycat in standalone mode does not support externally setting task configurations.");
}
/**
* Start a connector in the worker and record its state.
* @param connectorProps new connector configuration
* @return the connector name
*/
private String startConnector(Map<String, String> connectorProps) {
ConnectorConfig connConfig = new ConnectorConfig(connectorProps);
String connName = connConfig.getString(ConnectorConfig.NAME_CONFIG);
ConnectorState state = connectors.get(connName);
Map<ConnectorTaskId, Map<String, String>> taskConfigs = worker.reconfigureConnectorTasks(connName,
worker.addConnector(connConfig, new HerderConnectorContext(this, connName));
if (state == null) {
connectors.put(connName, new ConnectorState(connectorProps, connConfig));
} else {
state.configOriginals = connectorProps;
state.config = connConfig;
}
return connName;
}
private List<Map<String, String>> recomputeTaskConfigs(String connName) {
ConnectorState state = connectors.get(connName);
return worker.connectorTaskConfigs(connName,
state.config.getInt(ConnectorConfig.TASKS_MAX_CONFIG),
state.config.getList(ConnectorConfig.TOPICS_CONFIG));
}
for (Map.Entry<ConnectorTaskId, Map<String, String>> taskEntry : taskConfigs.entrySet()) {
ConnectorTaskId taskId = taskEntry.getKey();
TaskConfig config = new TaskConfig(taskEntry.getValue());
private void createConnectorTasks(String connName) {
ConnectorState state = connectors.get(connName);
int index = 0;
for (Map<String, String> taskConfigMap : state.taskConfigs) {
ConnectorTaskId taskId = new ConnectorTaskId(connName, index);
TaskConfig config = new TaskConfig(taskConfigMap);
try {
worker.addTask(taskId, config);
// We only need to store the task IDs so we can clean up.
state.tasks.add(taskId);
} catch (Throwable e) {
log.error("Failed to add task {}: ", taskId, e);
// Swallow this so we can continue updating the rest of the tasks
// FIXME what's the proper response? Kill all the tasks? Consider this the same as a task
// that died after starting successfully.
}
index++;
}
}
private void removeConnectorTasks(String connName) {
ConnectorState state = connectors.get(connName);
Iterator<ConnectorTaskId> taskIter = state.tasks.iterator();
while (taskIter.hasNext()) {
ConnectorTaskId taskId = taskIter.next();
for (int i = 0; i < state.taskConfigs.size(); i++) {
ConnectorTaskId taskId = new ConnectorTaskId(connName, i);
try {
worker.stopTask(taskId);
taskIter.remove();
} catch (CopycatException e) {
log.error("Failed to stop task {}: ", taskId, e);
// Swallow this so we can continue stopping the rest of the tasks
// FIXME: Forcibly kill the task?
}
}
state.taskConfigs = new ArrayList<>();
}
private void updateConnectorTasks(String connName) {
removeConnectorTasks(connName);
createConnectorTasks(connName);
List<Map<String, String>> newTaskConfigs = recomputeTaskConfigs(connName);
ConnectorState state = connectors.get(connName);
if (!newTaskConfigs.equals(state.taskConfigs)) {
removeConnectorTasks(connName);
state.taskConfigs = newTaskConfigs;
createConnectorTasks(connName);
}
}
private static class ConnectorState {
public String name;
public Map<String, String> configOriginals;
public ConnectorConfig config;
Set<ConnectorTaskId> tasks;
List<Map<String, String>> taskConfigs;
public ConnectorState(ConnectorConfig config) {
public ConnectorState(Map<String, String> configOriginals, ConnectorConfig config) {
this.name = config.getString(ConnectorConfig.NAME_CONFIG);
this.configOriginals = configOriginals;
this.config = config;
this.tasks = new HashSet<>();
this.taskConfigs = new ArrayList<>();
}
}
}

10
copycat/runtime/src/main/java/org/apache/kafka/copycat/util/ConnectorTaskId.java

@ -17,6 +17,9 @@ @@ -17,6 +17,9 @@
package org.apache.kafka.copycat.util;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
/**
@ -27,15 +30,18 @@ public class ConnectorTaskId implements Serializable, Comparable<ConnectorTaskId @@ -27,15 +30,18 @@ public class ConnectorTaskId implements Serializable, Comparable<ConnectorTaskId
private final String connector;
private final int task;
public ConnectorTaskId(String job, int task) {
this.connector = job;
@JsonCreator
public ConnectorTaskId(@JsonProperty("connector") String connector, @JsonProperty("task") int task) {
this.connector = connector;
this.task = task;
}
@JsonProperty
public String connector() {
return connector;
}
@JsonProperty
public int task() {
return task;
}

3
copycat/runtime/src/main/java/org/apache/kafka/copycat/util/ConvertingFutureCallback.java

@ -70,7 +70,8 @@ public abstract class ConvertingFutureCallback<U, T> implements Callback<U>, Fut @@ -70,7 +70,8 @@ public abstract class ConvertingFutureCallback<U, T> implements Callback<U>, Fut
@Override
public T get(long l, TimeUnit timeUnit)
throws InterruptedException, ExecutionException, TimeoutException {
finishedLatch.await(l, timeUnit);
if (!finishedLatch.await(l, timeUnit))
throw new TimeoutException("Timed out waiting for future");
return result();
}

4
copycat/runtime/src/main/java/org/apache/kafka/copycat/util/FutureCallback.java

@ -23,6 +23,10 @@ public class FutureCallback<T> extends ConvertingFutureCallback<T, T> { @@ -23,6 +23,10 @@ public class FutureCallback<T> extends ConvertingFutureCallback<T, T> {
super(underlying);
}
public FutureCallback() {
super(null);
}
@Override
public T convert(T result) {
return result;

4
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerSinkTaskTest.java

@ -20,10 +20,10 @@ package org.apache.kafka.copycat.runtime; @@ -20,10 +20,10 @@ package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.data.Schema;
import org.apache.kafka.copycat.data.SchemaAndValue;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.runtime.standalone.StandaloneConfig;
import org.apache.kafka.copycat.sink.SinkRecord;
import org.apache.kafka.copycat.sink.SinkTask;
import org.apache.kafka.copycat.sink.SinkTaskContext;
@ -101,7 +101,7 @@ public class WorkerSinkTaskTest extends ThreadedTest { @@ -101,7 +101,7 @@ public class WorkerSinkTaskTest extends ThreadedTest {
workerProps.setProperty("internal.value.converter", "org.apache.kafka.copycat.json.JsonConverter");
workerProps.setProperty("internal.key.converter.schemas.enable", "false");
workerProps.setProperty("internal.value.converter.schemas.enable", "false");
workerConfig = new WorkerConfig(workerProps);
workerConfig = new StandaloneConfig(workerProps);
workerTask = PowerMock.createPartialMock(
WorkerSinkTask.class, new String[]{"createConsumer", "createWorkerThread"},
taskId, sinkTask, workerConfig, keyConverter, valueConverter, time);

4
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerSourceTaskTest.java

@ -22,8 +22,8 @@ import org.apache.kafka.clients.producer.KafkaProducer; @@ -22,8 +22,8 @@ import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.data.Schema;
import org.apache.kafka.copycat.runtime.standalone.StandaloneConfig;
import org.apache.kafka.copycat.source.SourceRecord;
import org.apache.kafka.copycat.source.SourceTask;
import org.apache.kafka.copycat.source.SourceTaskContext;
@ -96,7 +96,7 @@ public class WorkerSourceTaskTest extends ThreadedTest { @@ -96,7 +96,7 @@ public class WorkerSourceTaskTest extends ThreadedTest {
workerProps.setProperty("internal.value.converter", "org.apache.kafka.copycat.json.JsonConverter");
workerProps.setProperty("internal.key.converter.schemas.enable", "false");
workerProps.setProperty("internal.value.converter.schemas.enable", "false");
config = new WorkerConfig(workerProps);
config = new StandaloneConfig(workerProps);
producerCallbacks = EasyMock.newCapture();
}

10
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/WorkerTest.java

@ -20,11 +20,11 @@ package org.apache.kafka.copycat.runtime; @@ -20,11 +20,11 @@ package org.apache.kafka.copycat.runtime;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.copycat.cli.WorkerConfig;
import org.apache.kafka.copycat.connector.Connector;
import org.apache.kafka.copycat.connector.ConnectorContext;
import org.apache.kafka.copycat.connector.Task;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.runtime.standalone.StandaloneConfig;
import org.apache.kafka.copycat.sink.SinkTask;
import org.apache.kafka.copycat.source.SourceRecord;
import org.apache.kafka.copycat.source.SourceTask;
@ -77,7 +77,7 @@ public class WorkerTest extends ThreadedTest { @@ -77,7 +77,7 @@ public class WorkerTest extends ThreadedTest {
workerProps.setProperty("internal.value.converter", "org.apache.kafka.copycat.json.JsonConverter");
workerProps.setProperty("internal.key.converter.schemas.enable", "false");
workerProps.setProperty("internal.value.converter.schemas.enable", "false");
config = new WorkerConfig(workerProps);
config = new StandaloneConfig(workerProps);
}
@Test
@ -203,14 +203,14 @@ public class WorkerTest extends ThreadedTest { @@ -203,14 +203,14 @@ public class WorkerTest extends ThreadedTest {
} catch (CopycatException e) {
// expected
}
Map<ConnectorTaskId, Map<String, String>> taskConfigs = worker.reconfigureConnectorTasks(CONNECTOR_ID, 2, Arrays.asList("foo", "bar"));
List<Map<String, String>> taskConfigs = worker.connectorTaskConfigs(CONNECTOR_ID, 2, Arrays.asList("foo", "bar"));
Properties expectedTaskProps = new Properties();
expectedTaskProps.setProperty("foo", "bar");
expectedTaskProps.setProperty(TaskConfig.TASK_CLASS_CONFIG, TestSourceTask.class.getName());
expectedTaskProps.setProperty(SinkTask.TOPICS_CONFIG, "foo,bar");
assertEquals(2, taskConfigs.size());
assertEquals(expectedTaskProps, taskConfigs.get(new ConnectorTaskId(CONNECTOR_ID, 0)));
assertEquals(expectedTaskProps, taskConfigs.get(new ConnectorTaskId(CONNECTOR_ID, 1)));
assertEquals(expectedTaskProps, taskConfigs.get(0));
assertEquals(expectedTaskProps, taskConfigs.get(1));
worker.stopConnector(CONNECTOR_ID);
assertEquals(Collections.emptySet(), worker.connectorNames());
// Nothing should be left, so this should effectively be a nop

236
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/distributed/DistributedHerderTest.java

@ -19,15 +19,22 @@ package org.apache.kafka.copycat.runtime.distributed; @@ -19,15 +19,22 @@ package org.apache.kafka.copycat.runtime.distributed;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.copycat.connector.ConnectorContext;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.TaskConfig;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.WorkerConfig;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.source.SourceConnector;
import org.apache.kafka.copycat.source.SourceTask;
import org.apache.kafka.copycat.storage.KafkaConfigStorage;
import org.apache.kafka.copycat.util.Callback;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.apache.kafka.copycat.util.FutureCallback;
import org.apache.kafka.copycat.util.TestFuture;
import org.easymock.Capture;
import org.easymock.EasyMock;
import org.easymock.IAnswer;
import org.junit.Before;
@ -40,57 +47,86 @@ import org.powermock.core.classloader.annotations.PrepareForTest; @@ -40,57 +47,86 @@ import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.powermock.reflect.Whitebox;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@RunWith(PowerMockRunner.class)
@PrepareForTest(DistributedHerder.class)
@PowerMockIgnore("javax.management.*")
public class DistributedHerderTest {
private static final Map<String, String> HERDER_CONFIG = new HashMap<>();
private static final Properties HERDER_CONFIG = new Properties();
static {
HERDER_CONFIG.put(KafkaConfigStorage.CONFIG_TOPIC_CONFIG, "config-topic");
HERDER_CONFIG.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
HERDER_CONFIG.put(DistributedHerderConfig.GROUP_ID_CONFIG, "test-copycat-group");
HERDER_CONFIG.put(DistributedConfig.GROUP_ID_CONFIG, "test-copycat-group");
// The WorkerConfig base class has some required settings without defaults
HERDER_CONFIG.put(WorkerConfig.KEY_CONVERTER_CLASS_CONFIG, "org.apache.kafka.copycat.json.JsonConverter");
HERDER_CONFIG.put(WorkerConfig.VALUE_CONVERTER_CLASS_CONFIG, "org.apache.kafka.copycat.json.JsonConverter");
HERDER_CONFIG.put(WorkerConfig.INTERNAL_KEY_CONVERTER_CLASS_CONFIG, "org.apache.kafka.copycat.json.JsonConverter");
HERDER_CONFIG.put(WorkerConfig.INTERNAL_VALUE_CONVERTER_CLASS_CONFIG, "org.apache.kafka.copycat.json.JsonConverter");
}
private static final String MEMBER_URL = "memberUrl";
private static final String CONN1 = "sourceA";
private static final String CONN2 = "sourceA";
private static final String CONN2 = "sourceB";
private static final ConnectorTaskId TASK0 = new ConnectorTaskId(CONN1, 0);
private static final ConnectorTaskId TASK1 = new ConnectorTaskId(CONN1, 1);
private static final ConnectorTaskId TASK2 = new ConnectorTaskId(CONN1, 2);
private static final Integer MAX_TASKS = 3;
private static final Map<String, String> CONNECTOR_CONFIG = new HashMap<>();
private static final Map<String, String> CONN1_CONFIG = new HashMap<>();
static {
CONN1_CONFIG.put(ConnectorConfig.NAME_CONFIG, CONN1);
CONN1_CONFIG.put(ConnectorConfig.TASKS_MAX_CONFIG, MAX_TASKS.toString());
CONN1_CONFIG.put(ConnectorConfig.TOPICS_CONFIG, "foo,bar");
CONN1_CONFIG.put(ConnectorConfig.CONNECTOR_CLASS_CONFIG, BogusSourceConnector.class.getName());
}
private static final Map<String, String> CONN1_CONFIG_UPDATED = new HashMap<>(CONN1_CONFIG);
static {
CONNECTOR_CONFIG.put(ConnectorConfig.NAME_CONFIG, "sourceA");
CONNECTOR_CONFIG.put(ConnectorConfig.TASKS_MAX_CONFIG, MAX_TASKS.toString());
CONNECTOR_CONFIG.put(ConnectorConfig.TOPICS_CONFIG, "foo,bar");
CONNECTOR_CONFIG.put(ConnectorConfig.CONNECTOR_CLASS_CONFIG, BogusSourceConnector.class.getName());
CONN1_CONFIG_UPDATED.put(ConnectorConfig.TOPICS_CONFIG, "foo,bar,baz");
}
private static final Map<String, String> CONN2_CONFIG = new HashMap<>();
static {
CONN2_CONFIG.put(ConnectorConfig.NAME_CONFIG, CONN2);
CONN2_CONFIG.put(ConnectorConfig.TASKS_MAX_CONFIG, MAX_TASKS.toString());
CONN2_CONFIG.put(ConnectorConfig.TOPICS_CONFIG, "foo,bar");
CONN2_CONFIG.put(ConnectorConfig.CONNECTOR_CLASS_CONFIG, BogusSourceConnector.class.getName());
}
private static final Map<String, String> TASK_CONFIG = new HashMap<>();
static {
TASK_CONFIG.put(TaskConfig.TASK_CLASS_CONFIG, BogusSourceTask.class.getName());
}
private static final HashMap<ConnectorTaskId, Map<String, String>> TASK_CONFIGS = new HashMap<>();
private static final List<Map<String, String>> TASK_CONFIGS = new ArrayList<>();
static {
TASK_CONFIGS.put(TASK0, TASK_CONFIG);
TASK_CONFIGS.put(TASK1, TASK_CONFIG);
TASK_CONFIGS.put(TASK2, TASK_CONFIG);
TASK_CONFIGS.add(TASK_CONFIG);
TASK_CONFIGS.add(TASK_CONFIG);
TASK_CONFIGS.add(TASK_CONFIG);
}
private static final HashMap<ConnectorTaskId, Map<String, String>> TASK_CONFIGS_MAP = new HashMap<>();
static {
TASK_CONFIGS_MAP.put(TASK0, TASK_CONFIG);
TASK_CONFIGS_MAP.put(TASK1, TASK_CONFIG);
TASK_CONFIGS_MAP.put(TASK2, TASK_CONFIG);
}
private static final ClusterConfigState SNAPSHOT = new ClusterConfigState(1, Collections.singletonMap(CONN1, 3),
Collections.singletonMap(CONN1, CONNECTOR_CONFIG), TASK_CONFIGS, Collections.<String>emptySet());
Collections.singletonMap(CONN1, CONN1_CONFIG), TASK_CONFIGS_MAP, Collections.<String>emptySet());
private static final ClusterConfigState SNAPSHOT_UPDATED_CONN1_CONFIG = new ClusterConfigState(1, Collections.singletonMap(CONN1, 3),
Collections.singletonMap(CONN1, CONN1_CONFIG_UPDATED), TASK_CONFIGS_MAP, Collections.<String>emptySet());
@Mock private KafkaConfigStorage configStorage;
@Mock private WorkerGroupMember member;
private DistributedHerder herder;
@Mock private Worker worker;
@Mock private Callback<String> createCallback;
@Mock private Callback<Void> destroyCallback;
@Mock private Callback<Herder.Created<ConnectorInfo>> putConnectorCallback;
private Callback<String> connectorConfigCallback;
private Callback<List<ConnectorTaskId>> taskConfigCallback;
@ -101,7 +137,7 @@ public class DistributedHerderTest { @@ -101,7 +137,7 @@ public class DistributedHerderTest {
worker = PowerMock.createMock(Worker.class);
herder = PowerMock.createPartialMock(DistributedHerder.class, new String[]{"backoff"},
worker, HERDER_CONFIG, configStorage, member);
new DistributedConfig(HERDER_CONFIG), worker, configStorage, member, MEMBER_URL);
connectorConfigCallback = Whitebox.invokeMethod(herder, "connectorConfigCallback");
taskConfigCallback = Whitebox.invokeMethod(herder, "taskConfigCallback");
rebalanceListener = Whitebox.invokeMethod(herder, "rebalanceListener");
@ -115,7 +151,7 @@ public class DistributedHerderTest { @@ -115,7 +151,7 @@ public class DistributedHerderTest {
expectPostRebalanceCatchup(SNAPSHOT);
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
worker.addTask(EasyMock.eq(TASK1), EasyMock.<TaskConfig>anyObject());
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
@ -156,9 +192,11 @@ public class DistributedHerderTest { @@ -156,9 +192,11 @@ public class DistributedHerderTest {
member.wakeup();
PowerMock.expectLastCall();
configStorage.putConnectorConfig(CONN1, CONNECTOR_CONFIG);
// CONN2 is new, should succeed
configStorage.putConnectorConfig(CONN2, CONN2_CONFIG);
PowerMock.expectLastCall();
createCallback.onCompletion(null, CONN1);
ConnectorInfo info = new ConnectorInfo(CONN2, CONN2_CONFIG, Collections.<ConnectorTaskId>emptyList());
putConnectorCallback.onCompletion(null, new Herder.Created<>(true, info));
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
@ -166,7 +204,30 @@ public class DistributedHerderTest { @@ -166,7 +204,30 @@ public class DistributedHerderTest {
PowerMock.replayAll();
herder.addConnector(CONNECTOR_CONFIG, createCallback);
herder.putConnectorConfig(CONN2, CONN2_CONFIG, false, putConnectorCallback);
herder.tick();
PowerMock.verifyAll();
}
@Test
public void testCreateConnectorAlreadyExists() throws Exception {
EasyMock.expect(member.memberId()).andStubReturn("leader");
expectRebalance(1, Collections.<String>emptyList(), Collections.<ConnectorTaskId>emptyList());
expectPostRebalanceCatchup(SNAPSHOT);
member.wakeup();
PowerMock.expectLastCall();
// CONN1 already exists
putConnectorCallback.onCompletion(EasyMock.<AlreadyExistsException>anyObject(), EasyMock.<Herder.Created<ConnectorInfo>>isNull());
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
// No immediate action besides this -- change will be picked up via the config log
PowerMock.replayAll();
herder.putConnectorConfig(CONN1, CONN1_CONFIG, false, putConnectorCallback);
herder.tick();
PowerMock.verifyAll();
@ -180,14 +241,14 @@ public class DistributedHerderTest { @@ -180,14 +241,14 @@ public class DistributedHerderTest {
expectPostRebalanceCatchup(SNAPSHOT);
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
// And delete the connector
member.wakeup();
PowerMock.expectLastCall();
configStorage.putConnectorConfig(CONN1, null);
PowerMock.expectLastCall();
destroyCallback.onCompletion(null, null);
putConnectorCallback.onCompletion(null, new Herder.Created<ConnectorInfo>(false, null));
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
@ -195,7 +256,7 @@ public class DistributedHerderTest { @@ -195,7 +256,7 @@ public class DistributedHerderTest {
PowerMock.replayAll();
herder.deleteConnector(CONN1, destroyCallback);
herder.putConnectorConfig(CONN1, null, true, putConnectorCallback);
herder.tick();
PowerMock.verifyAll();
@ -224,7 +285,7 @@ public class DistributedHerderTest { @@ -224,7 +285,7 @@ public class DistributedHerderTest {
CopycatProtocol.Assignment.NO_ERROR, 1, Arrays.asList(CONN1), Collections.<ConnectorTaskId>emptyList());
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
@ -250,7 +311,7 @@ public class DistributedHerderTest { @@ -250,7 +311,7 @@ public class DistributedHerderTest {
expectPostRebalanceCatchup(SNAPSHOT);
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
@ -263,7 +324,7 @@ public class DistributedHerderTest { @@ -263,7 +324,7 @@ public class DistributedHerderTest {
PowerMock.expectLastCall();
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
@ -322,7 +383,7 @@ public class DistributedHerderTest { @@ -322,7 +383,7 @@ public class DistributedHerderTest {
TestFuture<Void> readToEndFuture = new TestFuture<>();
readToEndFuture.resolveOnGet(new TimeoutException());
EasyMock.expect(configStorage.readToEnd()).andReturn(readToEndFuture);
PowerMock.expectPrivate(herder, "backoff", DistributedHerderConfig.WORKER_UNSYNC_BACKOFF_MS_DEFAULT);
PowerMock.expectPrivate(herder, "backoff", DistributedConfig.WORKER_UNSYNC_BACKOFF_MS_DEFAULT);
member.requestRejoin();
// After backoff, restart the process and this time succeed
@ -331,7 +392,7 @@ public class DistributedHerderTest { @@ -331,7 +392,7 @@ public class DistributedHerderTest {
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.reconfigureConnectorTasks(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
worker.addTask(EasyMock.eq(TASK1), EasyMock.<TaskConfig>anyObject());
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
@ -345,6 +406,123 @@ public class DistributedHerderTest { @@ -345,6 +406,123 @@ public class DistributedHerderTest {
PowerMock.verifyAll();
}
@Test
public void testAccessors() throws Exception {
EasyMock.expect(member.memberId()).andStubReturn("leader");
expectRebalance(1, Collections.<String>emptyList(), Collections.<ConnectorTaskId>emptyList());
expectPostRebalanceCatchup(SNAPSHOT);
member.wakeup();
PowerMock.expectLastCall().anyTimes();
// list connectors, get connector info, get connector config, get task configs
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
PowerMock.replayAll();
FutureCallback<Collection<String>> listConnectorsCb = new FutureCallback<>();
herder.connectors(listConnectorsCb);
FutureCallback<ConnectorInfo> connectorInfoCb = new FutureCallback<>();
herder.connectorInfo(CONN1, connectorInfoCb);
FutureCallback<Map<String, String>> connectorConfigCb = new FutureCallback<>();
herder.connectorConfig(CONN1, connectorConfigCb);
FutureCallback<List<TaskInfo>> taskConfigsCb = new FutureCallback<>();
herder.taskConfigs(CONN1, taskConfigsCb);
herder.tick();
assertTrue(listConnectorsCb.isDone());
assertEquals(Collections.singleton(CONN1), listConnectorsCb.get());
assertTrue(connectorInfoCb.isDone());
ConnectorInfo info = new ConnectorInfo(CONN1, CONN1_CONFIG, Arrays.asList(TASK0, TASK1, TASK2));
assertEquals(info, connectorInfoCb.get());
assertTrue(connectorConfigCb.isDone());
assertEquals(CONN1_CONFIG, connectorConfigCb.get());
assertTrue(taskConfigsCb.isDone());
assertEquals(Arrays.asList(
new TaskInfo(TASK0, TASK_CONFIG),
new TaskInfo(TASK1, TASK_CONFIG),
new TaskInfo(TASK2, TASK_CONFIG)),
taskConfigsCb.get());
PowerMock.verifyAll();
}
@Test
public void testPutConnectorConfig() throws Exception {
EasyMock.expect(member.memberId()).andStubReturn("leader");
expectRebalance(1, Arrays.asList(CONN1), Collections.<ConnectorTaskId>emptyList());
expectPostRebalanceCatchup(SNAPSHOT);
worker.addConnector(EasyMock.<ConnectorConfig>anyObject(), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
// list connectors, get connector info, get connector config, get task configs
member.wakeup();
PowerMock.expectLastCall().anyTimes();
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
// Poll loop for second round of calls
member.ensureActive();
PowerMock.expectLastCall();
configStorage.putConnectorConfig(CONN1, CONN1_CONFIG_UPDATED);
PowerMock.expectLastCall().andAnswer(new IAnswer<Object>() {
@Override
public Object answer() throws Throwable {
// Simulate response to writing config + waiting until end of log to be read
connectorConfigCallback.onCompletion(null, CONN1);
return null;
}
});
// As a result of reconfig, should need to update snapshot. With only connector updates, we'll just restart
// connector without rebalance
EasyMock.expect(configStorage.snapshot()).andReturn(SNAPSHOT_UPDATED_CONN1_CONFIG);
worker.stopConnector(CONN1);
PowerMock.expectLastCall();
Capture<ConnectorConfig> capturedUpdatedConfig = EasyMock.newCapture();
worker.addConnector(EasyMock.capture(capturedUpdatedConfig), EasyMock.<ConnectorContext>anyObject());
PowerMock.expectLastCall();
EasyMock.expect(worker.connectorTaskConfigs(CONN1, MAX_TASKS, null)).andReturn(TASK_CONFIGS);
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
// Third tick just to read the config
member.ensureActive();
PowerMock.expectLastCall();
member.poll(EasyMock.anyInt());
PowerMock.expectLastCall();
PowerMock.replayAll();
// Should pick up original config
FutureCallback<Map<String, String>> connectorConfigCb = new FutureCallback<>();
herder.connectorConfig(CONN1, connectorConfigCb);
herder.tick();
assertTrue(connectorConfigCb.isDone());
assertEquals(CONN1_CONFIG, connectorConfigCb.get());
// Apply new config.
FutureCallback<Herder.Created<ConnectorInfo>> putConfigCb = new FutureCallback<>();
herder.putConnectorConfig(CONN1, CONN1_CONFIG_UPDATED, true, putConfigCb);
herder.tick();
assertTrue(putConfigCb.isDone());
ConnectorInfo updatedInfo = new ConnectorInfo(CONN1, CONN1_CONFIG_UPDATED, Arrays.asList(TASK0, TASK1, TASK2));
assertEquals(new Herder.Created<>(false, updatedInfo), putConfigCb.get());
// Check config again to validate change
connectorConfigCb = new FutureCallback<>();
herder.connectorConfig(CONN1, connectorConfigCb);
herder.tick();
assertTrue(connectorConfigCb.isDone());
assertEquals(CONN1_CONFIG_UPDATED, connectorConfigCb.get());
// The config passed to Worker should
assertEquals(Arrays.asList("foo", "bar", "baz"),
capturedUpdatedConfig.getValue().getList(ConnectorConfig.TOPICS_CONFIG));
PowerMock.verifyAll();
}
@Test
public void testInconsistentConfigs() throws Exception {
// FIXME: if we have inconsistent configs, we need to request forced reconfig + write of the connector's task configs
@ -366,7 +544,7 @@ public class DistributedHerderTest { @@ -366,7 +544,7 @@ public class DistributedHerderTest {
if (revokedConnectors != null)
rebalanceListener.onRevoked("leader", revokedConnectors, revokedTasks);
CopycatProtocol.Assignment assignment = new CopycatProtocol.Assignment(
error, "leader", offset, assignedConnectors, assignedTasks);
error, "leader", "leaderUrl", offset, assignedConnectors, assignedTasks);
rebalanceListener.onAssigned(assignment);
return null;
}

21
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/distributed/WorkerCoordinatorTest.java

@ -55,6 +55,9 @@ import static org.junit.Assert.assertFalse; @@ -55,6 +55,9 @@ import static org.junit.Assert.assertFalse;
public class WorkerCoordinatorTest {
private static final String LEADER_URL = "leaderUrl:8083";
private static final String MEMBER_URL = "memberUrl:8083";
private String connectorId = "connector";
private String connectorId2 = "connector2";
private ConnectorTaskId taskId0 = new ConnectorTaskId(connectorId, 0);
@ -104,6 +107,7 @@ public class WorkerCoordinatorTest { @@ -104,6 +107,7 @@ public class WorkerCoordinatorTest {
time,
requestTimeoutMs,
retryBackoffMs,
LEADER_URL,
configStorage,
rebalanceListener);
@ -147,7 +151,7 @@ public class WorkerCoordinatorTest { @@ -147,7 +151,7 @@ public class WorkerCoordinatorTest {
LinkedHashMap<String, ByteBuffer> serialized = coordinator.metadata();
assertEquals(1, serialized.size());
CopycatProtocol.ConfigState state = CopycatProtocol.deserializeMetadata(serialized.get(WorkerCoordinator.DEFAULT_SUBPROTOCOL));
CopycatProtocol.WorkerState state = CopycatProtocol.deserializeMetadata(serialized.get(WorkerCoordinator.DEFAULT_SUBPROTOCOL));
assertEquals(1, state.offset());
PowerMock.verifyAll();
@ -322,8 +326,8 @@ public class WorkerCoordinatorTest { @@ -322,8 +326,8 @@ public class WorkerCoordinatorTest {
Map<String, ByteBuffer> configs = new HashMap<>();
// Mark everyone as in sync with configState1
configs.put("leader", CopycatProtocol.serializeMetadata(new CopycatProtocol.ConfigState(1L)));
configs.put("member", CopycatProtocol.serializeMetadata(new CopycatProtocol.ConfigState(1L)));
configs.put("leader", CopycatProtocol.serializeMetadata(new CopycatProtocol.WorkerState(LEADER_URL, 1L)));
configs.put("member", CopycatProtocol.serializeMetadata(new CopycatProtocol.WorkerState(MEMBER_URL, 1L)));
Map<String, ByteBuffer> result = Whitebox.invokeMethod(coordinator, "performAssignment", "leader", WorkerCoordinator.DEFAULT_SUBPROTOCOL, configs);
// configState1 has 1 connector, 1 task
@ -358,8 +362,8 @@ public class WorkerCoordinatorTest { @@ -358,8 +362,8 @@ public class WorkerCoordinatorTest {
Map<String, ByteBuffer> configs = new HashMap<>();
// Mark everyone as in sync with configState1
configs.put("leader", CopycatProtocol.serializeMetadata(new CopycatProtocol.ConfigState(1L)));
configs.put("member", CopycatProtocol.serializeMetadata(new CopycatProtocol.ConfigState(1L)));
configs.put("leader", CopycatProtocol.serializeMetadata(new CopycatProtocol.WorkerState(LEADER_URL, 1L)));
configs.put("member", CopycatProtocol.serializeMetadata(new CopycatProtocol.WorkerState(MEMBER_URL, 1L)));
Map<String, ByteBuffer> result = Whitebox.invokeMethod(coordinator, "performAssignment", "leader", WorkerCoordinator.DEFAULT_SUBPROTOCOL, configs);
// configState2 has 2 connector, 3 tasks and should trigger round robin assignment
@ -390,7 +394,10 @@ public class WorkerCoordinatorTest { @@ -390,7 +394,10 @@ public class WorkerCoordinatorTest {
Map<String, Long> configOffsets, short error) {
Map<String, ByteBuffer> metadata = new HashMap<>();
for (Map.Entry<String, Long> configStateEntry : configOffsets.entrySet()) {
ByteBuffer buf = CopycatProtocol.serializeMetadata(new CopycatProtocol.ConfigState(configStateEntry.getValue()));
// We need a member URL, but it doesn't matter for the purposes of this test. Just set it to the member ID
String memberUrl = configStateEntry.getKey();
long configOffset = configStateEntry.getValue();
ByteBuffer buf = CopycatProtocol.serializeMetadata(new CopycatProtocol.WorkerState(memberUrl, configOffset));
metadata.put(configStateEntry.getKey(), buf);
}
return new JoinGroupResponse(error, generationId, WorkerCoordinator.DEFAULT_SUBPROTOCOL, memberId, memberId, metadata).toStruct();
@ -403,7 +410,7 @@ public class WorkerCoordinatorTest { @@ -403,7 +410,7 @@ public class WorkerCoordinatorTest {
private Struct syncGroupResponse(short assignmentError, String leader, long configOffset, List<String> connectorIds,
List<ConnectorTaskId> taskIds, short error) {
CopycatProtocol.Assignment assignment = new CopycatProtocol.Assignment(assignmentError, leader, configOffset, connectorIds, taskIds);
CopycatProtocol.Assignment assignment = new CopycatProtocol.Assignment(assignmentError, leader, LEADER_URL, configOffset, connectorIds, taskIds);
ByteBuffer buf = CopycatProtocol.serializeAssignment(assignment);
return new SyncGroupResponse(error, buf).toStruct();
}

364
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/rest/resources/ConnectorsResourceTest.java

@ -0,0 +1,364 @@ @@ -0,0 +1,364 @@
/**
* 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.copycat.runtime.rest.resources;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.errors.CopycatException;
import org.apache.kafka.copycat.errors.NotFoundException;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.distributed.NotLeaderException;
import org.apache.kafka.copycat.runtime.rest.RestServer;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.CreateConnectorRequest;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.util.Callback;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.easymock.Capture;
import org.easymock.EasyMock;
import org.easymock.IAnswer;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.api.easymock.PowerMock;
import org.powermock.api.easymock.annotation.Mock;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
@RunWith(PowerMockRunner.class)
@PrepareForTest(RestServer.class)
@PowerMockIgnore("javax.management.*")
public class ConnectorsResourceTest {
// Note trailing / and that we do *not* use LEADER_URL to construct our reference values. This checks that we handle
// URL construction properly, avoiding //, which will mess up routing in the REST server
private static final String LEADER_URL = "http://leader:8083/";
private static final String CONNECTOR_NAME = "test";
private static final String CONNECTOR2_NAME = "test2";
private static final Map<String, String> CONNECTOR_CONFIG = new HashMap<>();
static {
CONNECTOR_CONFIG.put("name", CONNECTOR_NAME);
CONNECTOR_CONFIG.put("sample_config", "test_config");
}
private static final List<ConnectorTaskId> CONNECTOR_TASK_NAMES = Arrays.asList(
new ConnectorTaskId(CONNECTOR_NAME, 0),
new ConnectorTaskId(CONNECTOR_NAME, 1)
);
private static final List<Map<String, String>> TASK_CONFIGS = new ArrayList<>();
static {
TASK_CONFIGS.add(Collections.singletonMap("config", "value"));
TASK_CONFIGS.add(Collections.singletonMap("config", "other_value"));
}
private static final List<TaskInfo> TASK_INFOS = new ArrayList<>();
static {
TASK_INFOS.add(new TaskInfo(new ConnectorTaskId(CONNECTOR_NAME, 0), TASK_CONFIGS.get(0)));
TASK_INFOS.add(new TaskInfo(new ConnectorTaskId(CONNECTOR_NAME, 1), TASK_CONFIGS.get(1)));
}
@Mock
private Herder herder;
private ConnectorsResource connectorsResource;
@Before
public void setUp() throws NoSuchMethodException {
PowerMock.mockStatic(RestServer.class,
RestServer.class.getMethod("httpRequest", String.class, String.class, Object.class, TypeReference.class));
connectorsResource = new ConnectorsResource(herder);
}
@Test
public void testListConnectors() throws Throwable {
final Capture<Callback<Collection<String>>> cb = Capture.newInstance();
herder.connectors(EasyMock.capture(cb));
expectAndCallbackResult(cb, Arrays.asList(CONNECTOR2_NAME, CONNECTOR_NAME));
PowerMock.replayAll();
Collection<String> connectors = connectorsResource.listConnectors();
// Ordering isn't guaranteed, compare sets
assertEquals(new HashSet<>(Arrays.asList(CONNECTOR_NAME, CONNECTOR2_NAME)), new HashSet<>(connectors));
PowerMock.verifyAll();
}
@Test
public void testListConnectorsNotLeader() throws Throwable {
final Capture<Callback<Collection<String>>> cb = Capture.newInstance();
herder.connectors(EasyMock.capture(cb));
expectAndCallbackNotLeaderException(cb);
// Should forward request
EasyMock.expect(RestServer.httpRequest(EasyMock.eq("http://leader:8083/connectors"), EasyMock.eq("GET"),
EasyMock.isNull(), EasyMock.anyObject(TypeReference.class)))
.andReturn(new RestServer.HttpResponse<>(200, new HashMap<String, List<String>>(), Arrays.asList(CONNECTOR2_NAME, CONNECTOR_NAME)));
PowerMock.replayAll();
Collection<String> connectors = connectorsResource.listConnectors();
// Ordering isn't guaranteed, compare sets
assertEquals(new HashSet<>(Arrays.asList(CONNECTOR_NAME, CONNECTOR2_NAME)), new HashSet<>(connectors));
PowerMock.verifyAll();
}
@Test(expected = CopycatException.class)
public void testListConnectorsNotSynced() throws Throwable {
final Capture<Callback<Collection<String>>> cb = Capture.newInstance();
herder.connectors(EasyMock.capture(cb));
expectAndCallbackException(cb, new CopycatException("not synced"));
PowerMock.replayAll();
// throws
connectorsResource.listConnectors();
}
@Test
public void testCreateConnector() throws Throwable {
CreateConnectorRequest body = new CreateConnectorRequest(CONNECTOR_NAME, Collections.singletonMap(ConnectorConfig.NAME_CONFIG, CONNECTOR_NAME));
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(body.config()), EasyMock.eq(false), EasyMock.capture(cb));
expectAndCallbackResult(cb, new Herder.Created<>(true, new ConnectorInfo(CONNECTOR_NAME, CONNECTOR_CONFIG, CONNECTOR_TASK_NAMES)));
PowerMock.replayAll();
connectorsResource.createConnector(body);
PowerMock.verifyAll();
}
@Test
public void testCreateConnectorNotLeader() throws Throwable {
CreateConnectorRequest body = new CreateConnectorRequest(CONNECTOR_NAME, Collections.singletonMap(ConnectorConfig.NAME_CONFIG, CONNECTOR_NAME));
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(body.config()), EasyMock.eq(false), EasyMock.capture(cb));
expectAndCallbackNotLeaderException(cb);
// Should forward request
EasyMock.expect(RestServer.httpRequest(EasyMock.eq("http://leader:8083/connectors"), EasyMock.eq("POST"), EasyMock.eq(body), EasyMock.<TypeReference>anyObject()))
.andReturn(new RestServer.HttpResponse<>(201, new HashMap<String, List<String>>(), new ConnectorInfo(CONNECTOR_NAME, CONNECTOR_CONFIG, CONNECTOR_TASK_NAMES)));
PowerMock.replayAll();
connectorsResource.createConnector(body);
PowerMock.verifyAll();
}
@Test(expected = AlreadyExistsException.class)
public void testCreateConnectorExists() throws Throwable {
CreateConnectorRequest body = new CreateConnectorRequest(CONNECTOR_NAME, Collections.singletonMap(ConnectorConfig.NAME_CONFIG, CONNECTOR_NAME));
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(body.config()), EasyMock.eq(false), EasyMock.capture(cb));
expectAndCallbackException(cb, new AlreadyExistsException("already exists"));
PowerMock.replayAll();
connectorsResource.createConnector(body);
PowerMock.verifyAll();
}
@Test
public void testDeleteConnector() throws Throwable {
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.<Map<String, String>>isNull(), EasyMock.eq(true), EasyMock.capture(cb));
expectAndCallbackResult(cb, null);
PowerMock.replayAll();
connectorsResource.destroyConnector(CONNECTOR_NAME);
PowerMock.verifyAll();
}
@Test
public void testDeleteConnectorNotLeader() throws Throwable {
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.<Map<String, String>>isNull(), EasyMock.eq(true), EasyMock.capture(cb));
expectAndCallbackNotLeaderException(cb);
// Should forward request
EasyMock.expect(RestServer.httpRequest("http://leader:8083/connectors/" + CONNECTOR_NAME, "DELETE", null, null))
.andReturn(new RestServer.HttpResponse<>(204, new HashMap<String, List<String>>(), null));
PowerMock.replayAll();
connectorsResource.destroyConnector(CONNECTOR_NAME);
PowerMock.verifyAll();
}
// Not found exceptions should pass through to caller so they can be processed for 404s
@Test(expected = NotFoundException.class)
public void testDeleteConnectorNotFound() throws Throwable {
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.<Map<String, String>>isNull(), EasyMock.eq(true), EasyMock.capture(cb));
expectAndCallbackException(cb, new NotFoundException("not found"));
PowerMock.replayAll();
connectorsResource.destroyConnector(CONNECTOR_NAME);
PowerMock.verifyAll();
}
@Test
public void testGetConnector() throws Throwable {
final Capture<Callback<ConnectorInfo>> cb = Capture.newInstance();
herder.connectorInfo(EasyMock.eq(CONNECTOR_NAME), EasyMock.capture(cb));
expectAndCallbackResult(cb, new ConnectorInfo(CONNECTOR_NAME, CONNECTOR_CONFIG, CONNECTOR_TASK_NAMES));
PowerMock.replayAll();
ConnectorInfo connInfo = connectorsResource.getConnector(CONNECTOR_NAME);
assertEquals(new ConnectorInfo(CONNECTOR_NAME, CONNECTOR_CONFIG, CONNECTOR_TASK_NAMES), connInfo);
PowerMock.verifyAll();
}
@Test
public void testGetConnectorConfig() throws Throwable {
final Capture<Callback<Map<String, String>>> cb = Capture.newInstance();
herder.connectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.capture(cb));
expectAndCallbackResult(cb, CONNECTOR_CONFIG);
PowerMock.replayAll();
Map<String, String> connConfig = connectorsResource.getConnectorConfig(CONNECTOR_NAME);
assertEquals(CONNECTOR_CONFIG, connConfig);
PowerMock.verifyAll();
}
@Test(expected = NotFoundException.class)
public void testGetConnectorConfigConnectorNotFound() throws Throwable {
final Capture<Callback<Map<String, String>>> cb = Capture.newInstance();
herder.connectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.capture(cb));
expectAndCallbackException(cb, new NotFoundException("not found"));
PowerMock.replayAll();
connectorsResource.getConnectorConfig(CONNECTOR_NAME);
PowerMock.verifyAll();
}
@Test
public void testPutConnectorConfig() throws Throwable {
final Capture<Callback<Herder.Created<ConnectorInfo>>> cb = Capture.newInstance();
herder.putConnectorConfig(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(CONNECTOR_CONFIG), EasyMock.eq(true), EasyMock.capture(cb));
expectAndCallbackResult(cb, new Herder.Created<>(false, new ConnectorInfo(CONNECTOR_NAME, CONNECTOR_CONFIG, CONNECTOR_TASK_NAMES)));
PowerMock.replayAll();
connectorsResource.putConnectorConfig(CONNECTOR_NAME, CONNECTOR_CONFIG);
PowerMock.verifyAll();
}
@Test
public void testGetConnectorTaskConfigs() throws Throwable {
final Capture<Callback<List<TaskInfo>>> cb = Capture.newInstance();
herder.taskConfigs(EasyMock.eq(CONNECTOR_NAME), EasyMock.capture(cb));
expectAndCallbackResult(cb, TASK_INFOS);
PowerMock.replayAll();
List<TaskInfo> taskInfos = connectorsResource.getTaskConfigs(CONNECTOR_NAME);
assertEquals(TASK_INFOS, taskInfos);
PowerMock.verifyAll();
}
@Test(expected = NotFoundException.class)
public void testGetConnectorTaskConfigsConnectorNotFound() throws Throwable {
final Capture<Callback<List<TaskInfo>>> cb = Capture.newInstance();
herder.taskConfigs(EasyMock.eq(CONNECTOR_NAME), EasyMock.capture(cb));
expectAndCallbackException(cb, new NotFoundException("connector not found"));
PowerMock.replayAll();
connectorsResource.getTaskConfigs(CONNECTOR_NAME);
PowerMock.verifyAll();
}
@Test
public void testPutConnectorTaskConfigs() throws Throwable {
final Capture<Callback<Void>> cb = Capture.newInstance();
herder.putTaskConfigs(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(TASK_CONFIGS), EasyMock.capture(cb));
expectAndCallbackResult(cb, null);
PowerMock.replayAll();
connectorsResource.putTaskConfigs(CONNECTOR_NAME, TASK_CONFIGS);
PowerMock.verifyAll();
}
@Test(expected = NotFoundException.class)
public void testPutConnectorTaskConfigsConnectorNotFound() throws Throwable {
final Capture<Callback<Void>> cb = Capture.newInstance();
herder.putTaskConfigs(EasyMock.eq(CONNECTOR_NAME), EasyMock.eq(TASK_CONFIGS), EasyMock.capture(cb));
expectAndCallbackException(cb, new NotFoundException("not found"));
PowerMock.replayAll();
connectorsResource.putTaskConfigs(CONNECTOR_NAME, TASK_CONFIGS);
PowerMock.verifyAll();
}
private <T> void expectAndCallbackResult(final Capture<Callback<T>> cb, final T value) {
PowerMock.expectLastCall().andAnswer(new IAnswer<Void>() {
@Override
public Void answer() throws Throwable {
cb.getValue().onCompletion(null, value);
return null;
}
});
}
private <T> void expectAndCallbackException(final Capture<Callback<T>> cb, final Throwable t) {
PowerMock.expectLastCall().andAnswer(new IAnswer<Void>() {
@Override
public Void answer() throws Throwable {
cb.getValue().onCompletion(t, null);
return null;
}
});
}
private <T> void expectAndCallbackNotLeaderException(final Capture<Callback<T>> cb) {
expectAndCallbackException(cb, new NotLeaderException("not leader test", LEADER_URL));
}
}

228
copycat/runtime/src/test/java/org/apache/kafka/copycat/runtime/standalone/StandaloneHerderTest.java

@ -18,11 +18,17 @@ @@ -18,11 +18,17 @@
package org.apache.kafka.copycat.runtime.standalone;
import org.apache.kafka.copycat.connector.Connector;
import org.apache.kafka.copycat.connector.ConnectorContext;
import org.apache.kafka.copycat.connector.Task;
import org.apache.kafka.copycat.errors.AlreadyExistsException;
import org.apache.kafka.copycat.errors.NotFoundException;
import org.apache.kafka.copycat.runtime.ConnectorConfig;
import org.apache.kafka.copycat.runtime.Herder;
import org.apache.kafka.copycat.runtime.HerderConnectorContext;
import org.apache.kafka.copycat.runtime.TaskConfig;
import org.apache.kafka.copycat.runtime.Worker;
import org.apache.kafka.copycat.runtime.rest.entities.ConnectorInfo;
import org.apache.kafka.copycat.runtime.rest.entities.TaskInfo;
import org.apache.kafka.copycat.sink.SinkConnector;
import org.apache.kafka.copycat.sink.SinkTask;
import org.apache.kafka.copycat.source.SourceConnector;
@ -30,6 +36,7 @@ import org.apache.kafka.copycat.source.SourceTask; @@ -30,6 +36,7 @@ import org.apache.kafka.copycat.source.SourceTask;
import org.apache.kafka.copycat.util.Callback;
import org.apache.kafka.copycat.util.ConnectorTaskId;
import org.apache.kafka.copycat.util.FutureCallback;
import org.easymock.Capture;
import org.easymock.EasyMock;
import org.junit.Before;
import org.junit.Test;
@ -39,12 +46,18 @@ import org.powermock.api.easymock.annotation.Mock; @@ -39,12 +46,18 @@ import org.powermock.api.easymock.annotation.Mock;
import org.powermock.modules.junit4.PowerMockRunner;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@RunWith(PowerMockRunner.class)
public class StandaloneHerderTest {
private static final String CONNECTOR_NAME = "test";
@ -55,32 +68,39 @@ public class StandaloneHerderTest { @@ -55,32 +68,39 @@ public class StandaloneHerderTest {
private StandaloneHerder herder;
@Mock protected Worker worker;
private Connector connector;
@Mock protected Callback<String> createCallback;
private Map<String, String> connectorProps;
private Map<String, String> taskProps;
@Mock protected Callback<Herder.Created<ConnectorInfo>> createCallback;
@Before
public void setup() {
worker = PowerMock.createMock(Worker.class);
herder = new StandaloneHerder(worker);
}
connectorProps = new HashMap<>();
connectorProps.put(ConnectorConfig.NAME_CONFIG, CONNECTOR_NAME);
connectorProps.put(SinkConnector.TOPICS_CONFIG, TOPICS_LIST_STR);
@Test
public void testCreateSourceConnector() throws Exception {
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
PowerMock.replayAll();
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class), false, createCallback);
// These can be anything since connectors can pass along whatever they want.
taskProps = new HashMap<>();
taskProps.put("foo", "bar");
PowerMock.verifyAll();
}
@Test
public void testCreateSourceConnector() throws Exception {
public void testCreateConnectorAlreadyExists() throws Exception {
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(BogusSourceConnector.class, BogusSourceTask.class, false);
// First addition should succeed
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
// Second should fail
createCallback.onCompletion(EasyMock.<AlreadyExistsException>anyObject(), EasyMock.<Herder.Created<ConnectorInfo>>isNull());
PowerMock.expectLastCall();
PowerMock.replayAll();
herder.addConnector(connectorProps, createCallback);
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class), false, createCallback);
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class), false, createCallback);
PowerMock.verifyAll();
}
@ -88,11 +108,11 @@ public class StandaloneHerderTest { @@ -88,11 +108,11 @@ public class StandaloneHerderTest {
@Test
public void testCreateSinkConnector() throws Exception {
connector = PowerMock.createMock(BogusSinkConnector.class);
expectAdd(BogusSinkConnector.class, BogusSinkTask.class, true);
expectAdd(CONNECTOR_NAME, BogusSinkConnector.class, BogusSinkTask.class, true);
PowerMock.replayAll();
herder.addConnector(connectorProps, createCallback);
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSinkConnector.class), false, createCallback);
PowerMock.verifyAll();
}
@ -100,57 +120,172 @@ public class StandaloneHerderTest { @@ -100,57 +120,172 @@ public class StandaloneHerderTest {
@Test
public void testDestroyConnector() throws Exception {
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(BogusSourceConnector.class, BogusSourceTask.class, false);
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
expectDestroy();
PowerMock.replayAll();
herder.addConnector(connectorProps, createCallback);
FutureCallback<Void> futureCb = new FutureCallback<>(new Callback<Void>() {
@Override
public void onCompletion(Throwable error, Void result) {
PowerMock.replayAll();
}
});
herder.deleteConnector(CONNECTOR_NAME, futureCb);
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class), false, createCallback);
FutureCallback<Herder.Created<ConnectorInfo>> futureCb = new FutureCallback<>();
herder.putConnectorConfig(CONNECTOR_NAME, null, true, futureCb);
futureCb.get(1000L, TimeUnit.MILLISECONDS);
// Second deletion should fail since the connector is gone
futureCb = new FutureCallback<>();
herder.putConnectorConfig(CONNECTOR_NAME, null, true, futureCb);
try {
futureCb.get(1000L, TimeUnit.MILLISECONDS);
fail("Should have thrown NotFoundException");
} catch (ExecutionException e) {
assertTrue(e.getCause() instanceof NotFoundException);
}
PowerMock.verifyAll();
}
@Test
public void testCreateAndStop() throws Exception {
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(BogusSourceConnector.class, BogusSourceTask.class, false);
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
// herder.stop() should stop any running connectors and tasks even if destroyConnector was not invoked
expectStop();
PowerMock.replayAll();
herder.addConnector(connectorProps, createCallback);
herder.putConnectorConfig(CONNECTOR_NAME, connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class), false, createCallback);
herder.stop();
PowerMock.verifyAll();
}
private void expectAdd(Class<? extends Connector> connClass,
Class<? extends Task> taskClass,
@Test
public void testAccessors() throws Exception {
Map<String, String> connConfig = connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class);
Callback<Collection<String>> listConnectorsCb = PowerMock.createMock(Callback.class);
Callback<ConnectorInfo> connectorInfoCb = PowerMock.createMock(Callback.class);
Callback<Map<String, String>> connectorConfigCb = PowerMock.createMock(Callback.class);
Callback<List<TaskInfo>> taskConfigsCb = PowerMock.createMock(Callback.class);
// Check accessors with empty worker
listConnectorsCb.onCompletion(null, Collections.EMPTY_LIST);
EasyMock.expectLastCall();
connectorInfoCb.onCompletion(EasyMock.<NotFoundException>anyObject(), EasyMock.<ConnectorInfo>isNull());
EasyMock.expectLastCall();
connectorConfigCb.onCompletion(EasyMock.<NotFoundException>anyObject(), EasyMock.<Map<String, String>>isNull());
EasyMock.expectLastCall();
taskConfigsCb.onCompletion(EasyMock.<NotFoundException>anyObject(), EasyMock.<List<TaskInfo>>isNull());
EasyMock.expectLastCall();
// Create connector
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
// Validate accessors with 1 connector
listConnectorsCb.onCompletion(null, Arrays.asList(CONNECTOR_NAME));
EasyMock.expectLastCall();
ConnectorInfo connInfo = new ConnectorInfo(CONNECTOR_NAME, connConfig, Arrays.asList(new ConnectorTaskId(CONNECTOR_NAME, 0)));
connectorInfoCb.onCompletion(null, connInfo);
EasyMock.expectLastCall();
connectorConfigCb.onCompletion(null, connConfig);
EasyMock.expectLastCall();
TaskInfo taskInfo = new TaskInfo(new ConnectorTaskId(CONNECTOR_NAME, 0), taskConfig(BogusSourceTask.class, false));
taskConfigsCb.onCompletion(null, Arrays.asList(taskInfo));
EasyMock.expectLastCall();
PowerMock.replayAll();
// All operations are synchronous for StandaloneHerder, so we don't need to actually wait after making each call
herder.connectors(listConnectorsCb);
herder.connectorInfo(CONNECTOR_NAME, connectorInfoCb);
herder.connectorConfig(CONNECTOR_NAME, connectorConfigCb);
herder.taskConfigs(CONNECTOR_NAME, taskConfigsCb);
herder.putConnectorConfig(CONNECTOR_NAME, connConfig, false, createCallback);
herder.connectors(listConnectorsCb);
herder.connectorInfo(CONNECTOR_NAME, connectorInfoCb);
herder.connectorConfig(CONNECTOR_NAME, connectorConfigCb);
herder.taskConfigs(CONNECTOR_NAME, taskConfigsCb);
PowerMock.verifyAll();
}
@Test
public void testPutConnectorConfig() throws Exception {
Map<String, String> connConfig = connectorConfig(CONNECTOR_NAME, BogusSourceConnector.class);
Map<String, String> newConnConfig = new HashMap<>(connConfig);
newConnConfig.put("foo", "bar");
Callback<Map<String, String>> connectorConfigCb = PowerMock.createMock(Callback.class);
Callback<Herder.Created<ConnectorInfo>> putConnectorConfigCb = PowerMock.createMock(Callback.class);
// Create
connector = PowerMock.createMock(BogusSourceConnector.class);
expectAdd(CONNECTOR_NAME, BogusSourceConnector.class, BogusSourceTask.class, false);
// Should get first config
connectorConfigCb.onCompletion(null, connConfig);
EasyMock.expectLastCall();
// Update config, which requires stopping and restarting
worker.stopConnector(CONNECTOR_NAME);
EasyMock.expectLastCall();
Capture<ConnectorConfig> capturedConfig = EasyMock.newCapture();
worker.addConnector(EasyMock.capture(capturedConfig), EasyMock.<ConnectorContext>anyObject());
EasyMock.expectLastCall();
// Generate same task config, which should result in no additional action to restart tasks
EasyMock.expect(worker.connectorTaskConfigs(CONNECTOR_NAME, DEFAULT_MAX_TASKS, TOPICS_LIST))
.andReturn(Collections.singletonList(taskConfig(BogusSourceTask.class, false)));
ConnectorInfo newConnInfo = new ConnectorInfo(CONNECTOR_NAME, newConnConfig, Arrays.asList(new ConnectorTaskId(CONNECTOR_NAME, 0)));
putConnectorConfigCb.onCompletion(null, new Herder.Created<>(false, newConnInfo));
EasyMock.expectLastCall();
// Should get new config
connectorConfigCb.onCompletion(null, newConnConfig);
EasyMock.expectLastCall();
PowerMock.replayAll();
herder.putConnectorConfig(CONNECTOR_NAME, connConfig, false, createCallback);
herder.connectorConfig(CONNECTOR_NAME, connectorConfigCb);
herder.putConnectorConfig(CONNECTOR_NAME, newConnConfig, true, putConnectorConfigCb);
assertEquals("bar", capturedConfig.getValue().originals().get("foo"));
herder.connectorConfig(CONNECTOR_NAME, connectorConfigCb);
PowerMock.verifyAll();
}
@Test(expected = UnsupportedOperationException.class)
public void testPutTaskConfigs() {
Callback<Void> cb = PowerMock.createMock(Callback.class);
PowerMock.replayAll();
herder.putTaskConfigs(CONNECTOR_NAME,
Arrays.asList(Collections.singletonMap("config", "value")),
cb);
PowerMock.verifyAll();
}
private void expectAdd(String name, Class<? extends Connector> connClass, Class<? extends Task> taskClass,
boolean sink) throws Exception {
connectorProps.put(ConnectorConfig.CONNECTOR_CLASS_CONFIG, connClass.getName());
Map<String, String> connectorProps = connectorConfig(name, connClass);
worker.addConnector(EasyMock.eq(new ConnectorConfig(connectorProps)), EasyMock.anyObject(HerderConnectorContext.class));
PowerMock.expectLastCall();
createCallback.onCompletion(null, CONNECTOR_NAME);
ConnectorInfo connInfo = new ConnectorInfo(CONNECTOR_NAME, connectorProps, Arrays.asList(new ConnectorTaskId(CONNECTOR_NAME, 0)));
createCallback.onCompletion(null, new Herder.Created<>(true, connInfo));
PowerMock.expectLastCall();
// And we should instantiate the tasks. For a sink task, we should see added properties for
// the input topic partitions
Map<String, String> generatedTaskProps = new HashMap<>();
generatedTaskProps.putAll(taskProps);
generatedTaskProps.put(TaskConfig.TASK_CLASS_CONFIG, taskClass.getName());
if (sink)
generatedTaskProps.put(SinkTask.TOPICS_CONFIG, TOPICS_LIST_STR);
EasyMock.expect(worker.reconfigureConnectorTasks(CONNECTOR_NAME, DEFAULT_MAX_TASKS, TOPICS_LIST))
.andReturn(Collections.singletonMap(new ConnectorTaskId(CONNECTOR_NAME, 0), generatedTaskProps));
Map<String, String> generatedTaskProps = taskConfig(taskClass, sink);
EasyMock.expect(worker.connectorTaskConfigs(CONNECTOR_NAME, DEFAULT_MAX_TASKS, TOPICS_LIST))
.andReturn(Collections.singletonList(generatedTaskProps));
worker.addTask(new ConnectorTaskId(CONNECTOR_NAME, 0), new TaskConfig(generatedTaskProps));
PowerMock.expectLastCall();
@ -167,6 +302,25 @@ public class StandaloneHerderTest { @@ -167,6 +302,25 @@ public class StandaloneHerderTest {
expectStop();
}
private static HashMap<String, String> connectorConfig(String name, Class<? extends Connector> connClass) {
HashMap<String, String> connectorProps = new HashMap<>();
connectorProps.put(ConnectorConfig.NAME_CONFIG, name);
connectorProps.put(SinkConnector.TOPICS_CONFIG, TOPICS_LIST_STR);
connectorProps.put(ConnectorConfig.CONNECTOR_CLASS_CONFIG, connClass.getName());
return connectorProps;
}
private static Map<String, String> taskConfig(Class<? extends Task> taskClass, boolean sink) {
HashMap<String, String> generatedTaskProps = new HashMap<>();
// Connectors can add any settings, so these are arbitrary
generatedTaskProps.put("foo", "bar");
generatedTaskProps.put(TaskConfig.TASK_CLASS_CONFIG, taskClass.getName());
if (sink)
generatedTaskProps.put(SinkTask.TOPICS_CONFIG, TOPICS_LIST_STR);
return generatedTaskProps;
}
// We need to use a real class here due to some issue with mocking java.lang.Class
private abstract class BogusSourceConnector extends SourceConnector {
}

11
copycat/runtime/src/test/java/org/apache/kafka/copycat/storage/KafkaConfigStorageTest.java

@ -48,7 +48,6 @@ import java.util.ArrayList; @@ -48,7 +48,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -271,7 +270,7 @@ public class KafkaConfigStorageTest { @@ -271,7 +270,7 @@ public class KafkaConfigStorageTest {
assertEquals(3, configState.offset());
String connectorName = CONNECTOR_IDS.get(0);
assertEquals(Arrays.asList(connectorName), new ArrayList<>(configState.connectors()));
assertEquals(new HashSet<>(Arrays.asList(TASK_IDS.get(0), TASK_IDS.get(1))), configState.tasks(connectorName));
assertEquals(Arrays.asList(TASK_IDS.get(0), TASK_IDS.get(1)), configState.tasks(connectorName));
assertEquals(SAMPLE_CONFIGS.get(0), configState.taskConfig(TASK_IDS.get(0)));
assertEquals(SAMPLE_CONFIGS.get(1), configState.taskConfig(TASK_IDS.get(1)));
assertEquals(Collections.EMPTY_SET, configState.inconsistentConnectors());
@ -324,7 +323,7 @@ public class KafkaConfigStorageTest { @@ -324,7 +323,7 @@ public class KafkaConfigStorageTest {
// CONNECTOR_CONFIG_STRUCTS[2] -> SAMPLE_CONFIGS[2]
assertEquals(SAMPLE_CONFIGS.get(2), configState.connectorConfig(CONNECTOR_IDS.get(0)));
// Should see 2 tasks for that connector. Only config updates before the root key update should be reflected
assertEquals(new HashSet<>(Arrays.asList(TASK_IDS.get(0), TASK_IDS.get(1))), configState.tasks(CONNECTOR_IDS.get(0)));
assertEquals(Arrays.asList(TASK_IDS.get(0), TASK_IDS.get(1)), configState.tasks(CONNECTOR_IDS.get(0)));
// Both TASK_CONFIG_STRUCTS[0] -> SAMPLE_CONFIGS[0]
assertEquals(SAMPLE_CONFIGS.get(0), configState.taskConfig(TASK_IDS.get(0)));
assertEquals(SAMPLE_CONFIGS.get(0), configState.taskConfig(TASK_IDS.get(1)));
@ -390,11 +389,11 @@ public class KafkaConfigStorageTest { @@ -390,11 +389,11 @@ public class KafkaConfigStorageTest {
assertEquals(6, configState.offset()); // Should always be next to be read, not last committed
assertEquals(Arrays.asList(CONNECTOR_IDS.get(0)), new ArrayList<>(configState.connectors()));
// Inconsistent data should leave us with no tasks listed for the connector and an entry in the inconsistent list
assertEquals(Collections.EMPTY_SET, configState.tasks(CONNECTOR_IDS.get(0)));
assertEquals(Collections.EMPTY_LIST, configState.tasks(CONNECTOR_IDS.get(0)));
// Both TASK_CONFIG_STRUCTS[0] -> SAMPLE_CONFIGS[0]
assertNull(configState.taskConfig(TASK_IDS.get(0)));
assertNull(configState.taskConfig(TASK_IDS.get(1)));
assertEquals(new HashSet<>(Arrays.asList(CONNECTOR_IDS.get(0))), configState.inconsistentConnectors());
assertEquals(Collections.singleton(CONNECTOR_IDS.get(0)), configState.inconsistentConnectors());
// First try sending an invalid set of configs (can't possibly represent a valid config set for the tasks)
try {
@ -413,7 +412,7 @@ public class KafkaConfigStorageTest { @@ -413,7 +412,7 @@ public class KafkaConfigStorageTest {
// to the topic. Only the last call with 1 task config + 1 commit actually gets written.
assertEquals(8, configState.offset());
assertEquals(Arrays.asList(CONNECTOR_IDS.get(0)), new ArrayList<>(configState.connectors()));
assertEquals(new HashSet<>(Arrays.asList(TASK_IDS.get(0))), configState.tasks(CONNECTOR_IDS.get(0)));
assertEquals(Arrays.asList(TASK_IDS.get(0)), configState.tasks(CONNECTOR_IDS.get(0)));
assertEquals(SAMPLE_CONFIGS.get(0), configState.taskConfig(TASK_IDS.get(0)));
assertEquals(Collections.EMPTY_SET, configState.inconsistentConnectors());

81
tests/kafkatest/services/copycat.py

@ -15,9 +15,10 @@ @@ -15,9 +15,10 @@
from ducktape.services.service import Service
from ducktape.utils.util import wait_until
from ducktape.errors import DucktapeError
from kafkatest.services.kafka.directory import kafka_dir
import signal
import signal, random, requests
class CopycatServiceBase(Service):
"""Base class for Copycat services providing some common settings and functionality"""
@ -40,14 +41,14 @@ class CopycatServiceBase(Service): @@ -40,14 +41,14 @@ class CopycatServiceBase(Service):
except:
return []
def set_configs(self, config_template, connector_config_templates):
def set_configs(self, config_template_func, connector_config_templates=None):
"""
Set configurations for the worker and the connector to run on
it. These are not provided in the constructor because the worker
config generally needs access to ZK/Kafka services to
create the configuration.
"""
self.config_template = config_template
self.config_template_func = config_template_func
self.connector_config_templates = connector_config_templates
def stop_node(self, node, clean_shutdown=True):
@ -77,7 +78,52 @@ class CopycatServiceBase(Service): @@ -77,7 +78,52 @@ class CopycatServiceBase(Service):
node.account.ssh("rm -rf /mnt/copycat.pid /mnt/copycat.log /mnt/copycat.properties " + " ".join(self.config_filenames() + self.files), allow_fail=False)
def config_filenames(self):
return ["/mnt/copycat-connector-" + str(idx) + ".properties" for idx, template in enumerate(self.connector_config_templates)]
return ["/mnt/copycat-connector-" + str(idx) + ".properties" for idx, template in enumerate(self.connector_config_templates or [])]
def list_connectors(self, node=None):
return self._rest('/connectors', node=node)
def create_connector(self, config, node=None):
create_request = {
'name': config['name'],
'config': config
}
return self._rest('/connectors', create_request, node=node, method="POST")
def get_connector(self, name, node=None):
return self._rest('/connectors/' + name, node=node)
def get_connector_config(self, name, node=None):
return self._rest('/connectors/' + name + '/config', node=node)
def set_connector_config(self, name, config, node=None):
return self._rest('/connectors/' + name + '/config', config, node=node, method="PUT")
def get_connector_tasks(self, name, node=None):
return self._rest('/connectors/' + name + '/tasks', node=node)
def delete_connector(self, name, node=None):
return self._rest('/connectors/' + name, node=node, method="DELETE")
def _rest(self, path, body=None, node=None, method="GET"):
if node is None:
node = random.choice(self.nodes)
meth = getattr(requests, method.lower())
url = self._base_url(node) + path
resp = meth(url, json=body)
self.logger.debug("%s %s response: %d", url, method, resp.status_code)
if resp.status_code > 400:
raise CopycatRestError(resp.status_code, resp.text, resp.url)
if resp.status_code == 204:
return None
else:
return resp.json()
def _base_url(self, node):
return 'http://' + node.account.hostname + ':' + '8083'
class CopycatStandaloneService(CopycatServiceBase):
"""Runs Copycat in standalone mode."""
@ -91,7 +137,7 @@ class CopycatStandaloneService(CopycatServiceBase): @@ -91,7 +137,7 @@ class CopycatStandaloneService(CopycatServiceBase):
return self.nodes[0]
def start_node(self, node):
node.account.create_file("/mnt/copycat.properties", self.config_template)
node.account.create_file("/mnt/copycat.properties", self.config_template_func(node))
remote_connector_configs = []
for idx, template in enumerate(self.connector_config_templates):
target_file = "/mnt/copycat-connector-" + str(idx) + ".properties"
@ -116,23 +162,15 @@ class CopycatDistributedService(CopycatServiceBase): @@ -116,23 +162,15 @@ class CopycatDistributedService(CopycatServiceBase):
super(CopycatDistributedService, self).__init__(context, num_nodes, kafka, files)
self.offsets_topic = offsets_topic
self.configs_topic = configs_topic
self.first_start = True
def start_node(self, node):
node.account.create_file("/mnt/copycat.properties", self.config_template)
remote_connector_configs = []
for idx, template in enumerate(self.connector_config_templates):
target_file = "/mnt/copycat-connector-" + str(idx) + ".properties"
node.account.create_file(target_file, template)
remote_connector_configs.append(target_file)
node.account.create_file("/mnt/copycat.properties", self.config_template_func(node))
if self.connector_config_templates:
raise DucktapeError("Config files are not valid in distributed mode, submit connectors via the REST API")
self.logger.info("Starting Copycat distributed process")
with node.account.monitor_log("/mnt/copycat.log") as monitor:
cmd = "/opt/%s/bin/copycat-distributed.sh /mnt/copycat.properties " % kafka_dir(node)
# Only submit connectors on the first node so they don't get submitted multiple times. Also only submit them
# the first time the node is started so
if self.first_start and node == self.nodes[0]:
cmd += " ".join(remote_connector_configs)
cmd += " 1>> /mnt/copycat.log 2>> /mnt/copycat.log & echo $! > /mnt/copycat.pid"
node.account.ssh(cmd)
monitor.wait_until('Copycat started', timeout_sec=10, err_msg="Never saw message indicating Copycat finished startup")
@ -140,3 +178,14 @@ class CopycatDistributedService(CopycatServiceBase): @@ -140,3 +178,14 @@ class CopycatDistributedService(CopycatServiceBase):
if len(self.pids(node)) == 0:
raise RuntimeError("No process ids recorded")
class CopycatRestError(RuntimeError):
def __init__(self, status, msg, url):
self.status = status
self.message = msg
self.url = url
def __unicode__(self):
return "Copycat REST call failed: returned " + self.status + " for " + self.url + ". Response: " + self.message

8
tests/kafkatest/tests/copycat_distributed_test.py

@ -54,10 +54,15 @@ class CopycatDistributedFileTest(KafkaTest): @@ -54,10 +54,15 @@ class CopycatDistributedFileTest(KafkaTest):
self.value_converter = converter
self.schemas = schemas
self.cc.set_configs(self.render("copycat-distributed.properties"), [self.render("copycat-file-source.properties"), self.render("copycat-file-sink.properties")])
self.cc.set_configs(lambda node: self.render("copycat-distributed.properties", node=node))
self.cc.start()
self.logger.info("Creating connectors")
for connector_props in [self.render("copycat-file-source.properties"), self.render("copycat-file-sink.properties")]:
connector_config = dict([line.strip().split('=', 1) for line in connector_props.split('\n') if line.strip() and not line.strip().startswith('#')])
self.cc.create_connector(connector_config)
# Generating data on the source node should generate new records and create new output on the sink node. Timeouts
# here need to be more generous than they are for standalone mode because a) it takes longer to write configs,
# do rebalancing of the group, etc, and b) without explicit leave group support, rebalancing takes awhile
@ -80,7 +85,6 @@ class CopycatDistributedFileTest(KafkaTest): @@ -80,7 +85,6 @@ class CopycatDistributedFileTest(KafkaTest):
output_set = set(itertools.chain(*[
[line.strip() for line in self.file_contents(node, self.OUTPUT_FILE)] for node in self.cc.nodes
]))
#print input_set, output_set
return input_set == output_set

163
tests/kafkatest/tests/copycat_rest_test.py

@ -0,0 +1,163 @@ @@ -0,0 +1,163 @@
# 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.
from kafkatest.tests.kafka_test import KafkaTest
from kafkatest.services.copycat import CopycatDistributedService, CopycatRestError
from ducktape.utils.util import wait_until
import hashlib, subprocess, json, itertools
class CopycatRestApiTest(KafkaTest):
"""
Test of Copycat's REST API endpoints.
"""
INPUT_FILE = "/mnt/copycat.input"
INPUT_FILE2 = "/mnt/copycat.input2"
OUTPUT_FILE = "/mnt/copycat.output"
TOPIC = "test"
OFFSETS_TOPIC = "copycat-offsets"
CONFIG_TOPIC = "copycat-configs"
# Since tasks can be assigned to any node and we're testing with files, we need to make sure the content is the same
# across all nodes.
INPUT_LIST = ["foo", "bar", "baz"]
INPUTS = "\n".join(INPUT_LIST) + "\n"
LONGER_INPUT_LIST = ["foo", "bar", "baz", "razz", "ma", "tazz"]
LONER_INPUTS = "\n".join(LONGER_INPUT_LIST) + "\n"
SCHEMA = { "type": "string", "optional": False }
def __init__(self, test_context):
super(CopycatRestApiTest, self).__init__(test_context, num_zk=1, num_brokers=1, topics={
'test' : { 'partitions': 1, 'replication-factor': 1 }
})
self.cc = CopycatDistributedService(test_context, 2, self.kafka, [self.INPUT_FILE, self.INPUT_FILE2, self.OUTPUT_FILE])
def test_rest_api(self):
# Template parameters
self.key_converter = "org.apache.kafka.copycat.json.JsonConverter"
self.value_converter = "org.apache.kafka.copycat.json.JsonConverter"
self.schemas = True
self.cc.set_configs(lambda node: self.render("copycat-distributed.properties", node=node))
self.cc.start()
assert self.cc.list_connectors() == []
self.logger.info("Creating connectors")
source_connector_props = self.render("copycat-file-source.properties")
sink_connector_props = self.render("copycat-file-sink.properties")
for connector_props in [source_connector_props, sink_connector_props]:
connector_config = self._config_dict_from_props(connector_props)
self.cc.create_connector(connector_config)
# We should see the connectors appear
wait_until(lambda: set(self.cc.list_connectors()) == set(["local-file-source", "local-file-sink"]),
timeout_sec=10, err_msg="Connectors that were just created did not appear in connector listing")
# We'll only do very simple validation that the connectors and tasks really ran.
for node in self.cc.nodes:
node.account.ssh("echo -e -n " + repr(self.INPUTS) + " >> " + self.INPUT_FILE)
wait_until(lambda: self.validate_output(self.INPUT_LIST), timeout_sec=120, err_msg="Data added to input file was not seen in the output file in a reasonable amount of time.")
# Trying to create the same connector again should cause an error
try:
self.cc.create_connector(self._config_dict_from_props(source_connector_props))
assert False, "creating the same connector should have caused a conflict"
except CopycatRestError:
pass # expected
# Validate that we can get info about connectors
expected_source_info = {
'name': 'local-file-source',
'config': self._config_dict_from_props(source_connector_props),
'tasks': [{ 'connector': 'local-file-source', 'task': 0 }]
}
source_info = self.cc.get_connector("local-file-source")
assert expected_source_info == source_info, "Incorrect info:" + json.dumps(source_info)
source_config = self.cc.get_connector_config("local-file-source")
assert expected_source_info['config'] == source_config, "Incorrect config: " + json.dumps(source_config)
expected_sink_info = {
'name': 'local-file-sink',
'config': self._config_dict_from_props(sink_connector_props),
'tasks': [{ 'connector': 'local-file-sink', 'task': 0 }]
}
sink_info = self.cc.get_connector("local-file-sink")
assert expected_sink_info == sink_info, "Incorrect info:" + json.dumps(sink_info)
sink_config = self.cc.get_connector_config("local-file-sink")
assert expected_sink_info['config'] == sink_config, "Incorrect config: " + json.dumps(sink_config)
# Validate that we can get info about tasks. This info should definitely be available now without waiting since
# we've already seen data appear in files.
# TODO: It would be nice to validate a complete listing, but that doesn't make sense for the file connectors
expected_source_task_info = [{
'id': { 'connector': 'local-file-source', 'task': 0 },
'config': {
'task.class': 'org.apache.kafka.copycat.file.FileStreamSourceTask',
'file': self.INPUT_FILE,
'topic': self.TOPIC
}
}]
source_task_info = self.cc.get_connector_tasks("local-file-source")
assert expected_source_task_info == source_task_info, "Incorrect info:" + json.dumps(source_task_info)
expected_sink_task_info = [{
'id': { 'connector': 'local-file-sink', 'task': 0 },
'config': {
'task.class': 'org.apache.kafka.copycat.file.FileStreamSinkTask',
'file': self.OUTPUT_FILE,
'topics': self.TOPIC
}
}]
sink_task_info = self.cc.get_connector_tasks("local-file-sink")
assert expected_sink_task_info == sink_task_info, "Incorrect info:" + json.dumps(sink_task_info)
file_source_config = self._config_dict_from_props(source_connector_props)
file_source_config['file'] = self.INPUT_FILE2
self.cc.set_connector_config("local-file-source", file_source_config)
# We should also be able to verify that the modified configs caused the tasks to move to the new file and pick up
# more data.
for node in self.cc.nodes:
node.account.ssh("echo -e -n " + repr(self.LONER_INPUTS) + " >> " + self.INPUT_FILE2)
wait_until(lambda: self.validate_output(self.LONGER_INPUT_LIST), timeout_sec=120, err_msg="Data added to input file was not seen in the output file in a reasonable amount of time.")
self.cc.delete_connector("local-file-source")
self.cc.delete_connector("local-file-sink")
wait_until(lambda: len(self.cc.list_connectors()) == 0, timeout_sec=10, err_msg="Deleted connectors did not disappear from REST listing")
def validate_output(self, input):
input_set = set(input)
# Output needs to be collected from all nodes because we can't be sure where the tasks will be scheduled.
output_set = set(itertools.chain(*[
[line.strip() for line in self.file_contents(node, self.OUTPUT_FILE)] for node in self.cc.nodes
]))
return input_set == output_set
def file_contents(self, node, file):
try:
# Convert to a list here or the CalledProcessError may be returned during a call to the generator instead of
# immediately
return list(node.account.ssh_capture("cat " + file))
except subprocess.CalledProcessError:
return []
def _config_dict_from_props(self, connector_props):
return dict([line.strip().split('=', 1) for line in connector_props.split('\n') if line.strip() and not line.strip().startswith('#')])

4
tests/kafkatest/tests/copycat_test.py

@ -60,8 +60,8 @@ class CopycatStandaloneFileTest(KafkaTest): @@ -60,8 +60,8 @@ class CopycatStandaloneFileTest(KafkaTest):
self.value_converter = converter
self.schemas = schemas
self.source.set_configs(self.render("copycat-standalone.properties"), [self.render("copycat-file-source.properties")])
self.sink.set_configs(self.render("copycat-standalone.properties"), [self.render("copycat-file-sink.properties")])
self.source.set_configs(lambda node: self.render("copycat-standalone.properties", node=node), [self.render("copycat-file-source.properties")])
self.sink.set_configs(lambda node: self.render("copycat-standalone.properties", node=node), [self.render("copycat-file-sink.properties")])
self.source.start()
self.sink.start()

2
tests/kafkatest/tests/templates/copycat-distributed.properties

@ -36,3 +36,5 @@ config.storage.topic={{ CONFIG_TOPIC }} @@ -36,3 +36,5 @@ config.storage.topic={{ CONFIG_TOPIC }}
# Make sure data gets flushed frequently so tests don't have to wait to ensure they see data in output systems
offset.flush.interval.ms=5000
rest.advertised.host.name = {{ node.account.hostname }}

2
tests/setup.py

@ -30,5 +30,5 @@ setup(name="kafkatest", @@ -30,5 +30,5 @@ setup(name="kafkatest",
license="apache2.0",
packages=find_packages(),
include_package_data=True,
install_requires=["ducktape==0.3.8"]
install_requires=["ducktape==0.3.8", "requests>=2.5.0"]
)

4
vagrant/system-test-Vagrantfile.local

@ -21,3 +21,7 @@ num_zookeepers = 0 @@ -21,3 +21,7 @@ num_zookeepers = 0
num_brokers = 0
num_workers = 9
base_box = "kafkatest-worker"
# System tests use hostnames for each worker that need to be defined in /etc/hosts on the host running ducktape
enable_dns = true
Loading…
Cancel
Save