kafka-connect
>-
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
>-
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
| name | kafka-connect |
| description | >- |
Build production-grade Kafka Connect source and sink connector plugins in Java.
Before writing code, establish these. Skip questions where the answer is obvious from context.
Core:
Source-specific:
Sink-specific:
Both:
my-connector/
โโโ pom.xml
โโโ src/
โโโ main/java/com/example/connect/
โ โโโ MySourceConnector.java (or MySinkConnector.java)
โ โโโ MySourceTask.java (or MySinkTask.java)
โ โโโ MyConnectorConfig.java
โโโ test/java/com/example/connect/
โโโ MyConnectorConfigTest.java
โโโ MySourceTaskTest.java
โโโ MySourceConnectorIT.java (integration test)
For the full Maven POM template, read references/build-and-package.md.
public class MySourceConnector extends SourceConnector {
private Map<String, String> configProps;
@Override
public void start(Map<String, String> props) {
this.configProps = props;
// Validate config eagerly โ fail fast on bad config
new MyConnectorConfig(props);
}
@Override
public Class<? extends Task> taskClass() {
return MySourceTask.class;
}
@Override
public List<Map<String, String>> taskConfigs(int maxTasks) {
// Partition work across tasks. Each map configures one task.
// Example: assign tables/endpoints/partitions to tasks.
// Return at most maxTasks configs, but can return fewer.
List<Map<String, String>> configs = new ArrayList<>();
// ... partition logic ...
return configs;
}
@Override
public void stop() {
// Release any resources opened in start()
}
@Override
public ConfigDef config() {
return MyConnectorConfig.CONFIG_DEF;
}
@Override
public String version() {
return "1.0.0";
}
}
public class MySourceTask extends SourceTask {
private volatile boolean stopping = false;
private MyConnectorConfig config;
// ... client/connection fields ...
@Override
public void start(Map<String, String> props) {
this.config = new MyConnectorConfig(props);
// Restore offset from last committed position
Map<String, Object> sourcePartition = Collections.singletonMap(
"source", config.getString("my.source.id")
);
Map<String, Object> lastOffset = context.offsetStorageReader()
.offset(sourcePartition);
if (lastOffset != null) {
// Resume from stored position
// e.g., lastOffset.get("position")
}
// Open connection to external system
}
@Override
public List<SourceRecord> poll() throws InterruptedException {
if (stopping) return null;
// Fetch data from external system
List<SourceRecord> records = new ArrayList<>();
// If no data available, backoff โ don't spin
if (noNewData) {
Thread.sleep(config.getLong("poll.interval.ms"));
return Collections.emptyList();
}
for (/* each datum */) {
Map<String, Object> sourcePartition = Collections.singletonMap(
"source", config.getString("my.source.id")
);
Map<String, Object> sourceOffset = Collections.singletonMap(
"position", currentPosition // Use source-native coordinate
);
Schema valueSchema = SchemaBuilder.struct()
.name("com.example.MyRecord")
.field("id", Schema.INT64_SCHEMA)
.field("name", Schema.STRING_SCHEMA)
.field("updated_at", Timestamp.SCHEMA)
.build();
Struct value = new Struct(valueSchema)
.put("id", datum.getId())
.put("name", datum.getName())
.put("updated_at", datum.getUpdatedAt());
records.add(new SourceRecord(
sourcePartition, sourceOffset,
config.getString("topic"),
null, // partition (null = default partitioner)
Schema.INT64_SCHEMA, datum.getId(), // key
valueSchema, value // value
));
}
return records;
}
@Override
public void stop() {
// Called from a DIFFERENT THREAD than poll()
stopping = true;
// Close connections, release resources
}
}
Key source patterns:
offsetStorageReader() in start() โ resume from where you left offstop() runs on a different thread. Use a volatile boolean flag that poll() checks.Same lifecycle pattern as SourceConnector โ start(), taskClass(), taskConfigs(), stop(), config(), version(). The only structural difference is it extends SinkConnector.
public class MySinkTask extends SinkTask {
private MyConnectorConfig config;
// ... client/connection fields ...
private ErrantRecordReporter reporter;
@Override
public void start(Map<String, String> props) {
this.config = new MyConnectorConfig(props);
// Open connection to external system
// ErrantRecordReporter for DLQ support (Connect 2.6+)
try {
reporter = context.errantRecordReporter();
} catch (NoSuchMethodError | NoClassDefFoundError e) {
reporter = null; // Older Connect runtime
}
}
@Override
public void put(Collection<SinkRecord> records) {
if (records.isEmpty()) return;
for (SinkRecord record : records) {
try {
// Convert record to external system format
// Write to external system (batch for efficiency)
} catch (Exception e) {
if (reporter != null) {
reporter.report(record, e); // Route to DLQ
} else {
throw new ConnectException(
sanitizeMessage("Failed to write record", e), e
);
}
}
}
// Flush batch to external system
}
@Override
public void flush(Map<TopicPartition, OffsetAndMetadata> offsets) {
// Ensure all buffered writes are committed to the external system.
// Called before Connect commits consumer offsets.
}
@Override
public void open(Collection<TopicPartition> partitions) {
// Called after rebalance โ set up per-partition resources
}
@Override
public void close(Collection<TopicPartition> partitions) {
// Called before rebalance โ tear down per-partition resources
}
@Override
public void stop() {
// Close connections, release resources
}
}
Key sink patterns:
put(), flush on threshold (count or size). Don't write one record at a time.session.timeout.ms or the task gets evicted from the consumer group.open()/close() bracket partition assignments. Use them if your external system has per-partition state (e.g., per-partition files, connections, or transactions).public class MyConnectorConfig extends AbstractConfig {
public static final ConfigDef CONFIG_DEF = new ConfigDef()
// Connection
.define("connection.url", Type.STRING, ConfigDef.NO_DEFAULT_VALUE,
Importance.HIGH, "URL of the external system")
.define("connection.user", Type.STRING, "",
Importance.HIGH, "Username for authentication")
.define("connection.password", Type.PASSWORD, "",
Importance.HIGH, "Password for authentication")
// Behavior
.define("topic", Type.STRING, ConfigDef.NO_DEFAULT_VALUE,
Importance.HIGH, "Kafka topic to write to")
.define("tasks.max", Type.INT, 1,
Range.atLeast(1), Importance.MEDIUM,
"Maximum number of tasks")
.define("poll.interval.ms", Type.LONG, 5000L,
Range.atLeast(100), Importance.MEDIUM,
"Interval between polls when no data is available")
// SSL (if applicable)
.define("ssl.enabled", Type.BOOLEAN, false,
Importance.MEDIUM, "Enable SSL/TLS")
.define("ssl.truststore.location", Type.STRING, "",
Importance.MEDIUM, "Path to SSL truststore")
.define("ssl.truststore.password", Type.PASSWORD, "",
Importance.MEDIUM, "Truststore password");
public MyConnectorConfig(Map<String, String> props) {
super(CONFIG_DEF, props);
}
}
ConfigDef rules:
Type.PASSWORD for passwords, API keys, tokens, secrets โ anything sensitiveRange.atLeast(), Range.between(), ValidString.in(), NonEmptyStringImportance.HIGH = required for basic operation. MEDIUM = common tuning. LOW = advanced.group and orderInGroup parameters for UI displayAbstractConfig โ don't parse raw props manuallygetString(), getInt(), getPassword().value() โ never props.get()Connectors handle credentials and connect to external systems. Getting security wrong causes production incidents.
Type.PASSWORD in ConfigDef
[hidden])getPassword() returns a Password object โ call .value() only at point of use (authentication)${vault:/secret/path}, ${file:/path/to/creds}, ${env:API_KEY}start(), but your code should not cache or log the resolved valuesprops, originals(), values()) โ it contains resolved passwordslog.error("Failed to connect to {}", host, e)) โ never string concatenation with sensitive values// BAD: logs password in clear text
log.info("Starting connector with config: {}", props);
// BAD: JDBC URL may contain password
log.error("Connection failed: " + e.getMessage());
// GOOD: log only what's needed
log.info("Starting connector for host={}, database={}", config.getString("host"),
config.getString("database"));
// GOOD: sanitize error messages
log.error("Connection failed to host {}", config.getString("host"), e);
When wrapping exceptions in ConnectException, strip credentials:
private String sanitizeMessage(String context, Exception e) {
String msg = e.getMessage();
if (msg != null) {
// Strip common credential patterns from URLs
msg = msg.replaceAll("://[^@]+@", "://***@");
// Strip password= parameters
msg = msg.replaceAll("password=[^&\\s]+", "password=***");
}
return context + ": " + msg;
}
For connectors that communicate over the network, define SSL configs following Kafka's naming convention:
ssl.enabled (BOOLEAN), ssl.truststore.location (STRING), ssl.truststore.password (PASSWORD)ssl.keystore.location (STRING), ssl.keystore.password (PASSWORD), ssl.key.password (PASSWORD)ssl.enabled.protocols (STRING, default "TLSv1.2,TLSv1.3")sourcePartition and sourceOffset maps are stored in Kafka's internal connect-offsets topic โ never include credentials, tokens, or PII in these mapsBuild schemas with SchemaBuilder, populate with Struct:
// Define a schema
Schema schema = SchemaBuilder.struct()
.name("com.example.Order")
.version(1)
.field("id", Schema.INT64_SCHEMA)
.field("customer", Schema.STRING_SCHEMA)
.field("amount", Decimal.schema(2)) // Logical type
.field("created_at", Timestamp.SCHEMA) // Logical type
.field("notes", Schema.OPTIONAL_STRING_SCHEMA) // Nullable field
.build();
// Populate a struct
Struct value = new Struct(schema)
.put("id", 42L)
.put("customer", "Acme Corp")
.put("amount", new BigDecimal("99.95"))
.put("created_at", new java.util.Date())
.put("notes", null);
Schema types: INT8, INT16, INT32, INT64, FLOAT32, FLOAT64, BOOLEAN, STRING, BYTES, ARRAY, MAP, STRUCT
Logical types (named schemas over primitive types):
org.apache.kafka.connect.data.Timestamp โ milliseconds since epoch as INT64org.apache.kafka.connect.data.Date โ days since epoch as INT32org.apache.kafka.connect.data.Time โ milliseconds since midnight as INT32org.apache.kafka.connect.data.Decimal โ BYTES with scale parameterSchemaless mode: When you don't control the source schema, use Schema.STRING_SCHEMA with a JSON string value, or use a Map<String, Object> with null schema.
For the full Schema, SchemaBuilder, Struct, and ConfigDef API surface, read references/connect-api.md.
Patterns proven at scale in Debezium and Confluent connectors. Apply the ones relevant to your connector.
Most source connectors need to handle existing data, not just new changes. Expose snapshot mode as config:
Design snapshots as a pluggable strategy so modes can be added without changing core logic.
When the source system is quiet, no records flow and Connect's offsets don't advance. This causes:
Fix: emit periodic heartbeat records on a dedicated topic (e.g., __heartbeat.my-connector). Configurable interval (default 30s). The heartbeat record carries the current source position, keeping offsets fresh.
{"server": "prod-db-1", "lsn": "0/16B3748"} not {"pos": 23847293}Register MBeans for operational visibility:
kafka.connect:type=connector-task-metrics,connector=...,task=...For complex source connectors, consider a signal channel (database table, API endpoint, or Kafka topic) that operators can use to trigger actions without restarting:
SourceTask.stop() is called from a different thread than poll(). Use a volatile boolean flag โ don't rely on closing a connection that poll() is using without synchronization.SinkTask.put() and flush() must complete within the consumer's session.timeout.ms (default 10s). If your writes are slow, increase this timeout or reduce batch size.null or an empty list when no data. Sleep or use the external system's blocking read. The framework calls poll() again immediately โ if you don't throttle, you'll saturate the CPU.OPTIONAL with defaults for backward compatibility. Removing or renaming fields breaks existing consumers.Map<String, ?>, not scalars. Design your partition/offset key structure before writing code.taskConfigs(maxTasks) can return fewer configs than maxTasks, but not more. Return one config per logical partition of work.connect-api, connect-runtime, kafka-clients) must be <scope>provided</scope> in your POM โ the Connect worker already has them on the classpath. Bundling them causes version conflicts.context.raiseError(e) to move the connector to FAILED state. Don't swallow exceptions silently โ it makes debugging impossible.| Guide | Read When |
|---|---|
| references/connect-api.md | Need exact method signatures for Connector, Task, Schema, SchemaBuilder, Struct, ConfigDef, Context interfaces |
| references/build-and-package.md | Setting up Maven POM, packaging as plugin directory or uber-JAR, testing with Testcontainers, deployment |