7.3 – Sink Connector Implementation
Overview
This module teaches how to implement Sink Connectors that push data from Kafka to external systems. By the end, you will understand:
SinkTaskmechanics and theput()method.- Batch writes and buffering strategies.
- Delivery guarantees and idempotent writes.
- External system integration patterns.
This module focuses exclusively on Sink Connector development. Source Connector development was covered in Module 7.2.
Sink Connector Architecture
Data Flow
Kafka Topic
│
▼
Internal Kafka Consumer ───► SinkTask.put(records)
│
├─ Batch records
├─ Transform to external format
│
▼
External System (e.g., Database, Elasticsearch, S3)
│
▼
Offset Commit ───► __consumer_offsets topic
Key Responsibilities
- Consume from Kafka: Internal consumer fetches records from assigned partitions.
- Buffer Records: Accumulate records into batches for efficient writes.
- Transform to External Format: Convert
SinkRecordto external system format (e.g., SQL INSERT, JSON document). - Write to External System: Execute batch writes (e.g., database transaction, bulk API call).
- Commit Offsets: Commit Kafka consumer offsets after successful writes.
The SinkTask API
Interface: org.apache.kafka.connect.sink.SinkTask
All sink tasks extend SinkTask and implement three key methods:
void start(Map<String, String> props): Initialize the task.void put(Collection<SinkRecord> records): Process a batch of records from Kafka.void stop(): Clean up resources.
Implementing start(Map<String, String> props)
Purpose
Initialize the task with task-specific configuration and connect to the external system.
Example: JDBC Sink Task
@Override
public void start(Map<String, String> props) {
// Parse task configuration
String connectionUrl = props.get("connection.url");
String tableName = props.get("table.name");
// Connect to database
try {
this.connection = DriverManager.getConnection(connectionUrl);
this.connection.setAutoCommit(false); // Use transactions
} catch (SQLException e) {
throw new ConnectException("Failed to connect to database", e);
}
// Initialize prepared statement
try {
String sql = String.format(
"INSERT INTO %s (id, name, email) VALUES (?, ?, ?) " +
"ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email",
tableName
);
this.preparedStatement = connection.prepareStatement(sql);
} catch (SQLException e) {
throw new ConnectException("Failed to prepare statement", e);
}
}
Key Steps
- Parse Configuration: Extract task-specific properties.
- Connect to External System: Establish database connection, HTTP client, S3 client, etc.
- Prepare Resources: Create prepared statements, initialize buffers, etc.
Implementing put(Collection<SinkRecord> records)
Purpose
Process a batch of records from Kafka and write them to the external system.
Signature
@Override
public void put(Collection<SinkRecord> records) {
// Iterate over records
// Write to external system
// Commit transaction (if applicable)
}
Put Loop Behavior
Connect runtime repeatedly calls put():
- Consumer fetches records from Kafka (batch size controlled by
consumer.max.poll.records). - Connect calls
put(records)with the fetched batch. - Task writes records to external system.
- Connect commits Kafka consumer offsets (after
put()completes without exception). - Repeat (loop continues until task is stopped).
Example: JDBC Sink Task
@Override
public void put(Collection<SinkRecord> records) {
try {
for (SinkRecord record : records) {
// Extract value (assume Struct format)
Struct value = (Struct) record.value();
long id = value.getInt64("id");
String name = value.getString("name");
String email = value.getString("email");
// Add to batch
preparedStatement.setLong(1, id);
preparedStatement.setString(2, name);
preparedStatement.setString(3, email);
preparedStatement.addBatch();
}
// Execute batch
int[] results = preparedStatement.executeBatch();
// Commit transaction
connection.commit();
} catch (SQLException e) {
// Rollback on error
try {
connection.rollback();
} catch (SQLException rollbackEx) {
// Log rollback failure
}
throw new ConnectException("Failed to write to database", e);
}
}
Key Steps
- Iterate Records: Loop over
SinkRecordobjects in the batch. - Extract Data: Parse record key/value (may be Struct, Map, or primitive).
- Buffer Writes: Add to batch (e.g.,
PreparedStatement.addBatch(), buffer list). - Execute Batch: Write all records in one operation (e.g.,
executeBatch(), bulk API call). - Commit: Commit transaction or finalize write.
- Handle Errors: Rollback on failure, throw
ConnectExceptionto trigger retry/DLQ.
SinkRecord Anatomy
Key Fields
public class SinkRecord {
String topic(); // Kafka topic name
int kafkaPartition(); // Kafka partition
long kafkaOffset(); // Kafka offset
Schema keySchema(); // Key schema (nullable)
Object key(); // Key (nullable)
Schema valueSchema(); // Value schema
Object value(); // Value
Long timestamp(); // Record timestamp (milliseconds)
Map<String, ?> headers(); // Record headers
}
Example: Extract Value
Struct value = (Struct) record.value();
long userId = value.getInt64("user_id");
String userName = value.getString("name");
Example: Use Key for Primary Key
String key = (String) record.key();
// Use key as primary key in database
Schema and Struct Handling in Sink Connectors
In sink connectors, you'll often need to extract data from Schema + Struct objects (produced by source connectors or Kafka producers using structured formats) and convert them to the external system's format.
Why Schema and Struct?
Kafka Connect uses org.apache.kafka.connect.data.Schema and org.apache.kafka.connect.data.Struct to represent structured data. Sink tasks must:
- Parse Schema: Understand field types and structure.
- Extract Values: Retrieve field values from Struct.
- Map to External System: Convert to database columns, JSON, etc.
Reading Schema and Struct
@Override
public void put(Collection<SinkRecord> records) {
for (SinkRecord record : records) {
// Get schema and value
Schema valueSchema = record.valueSchema();
Object valueObject = record.value();
// Cast to Struct (if schema is present)
if (valueSchema != null && valueObject instanceof Struct) {
Struct value = (Struct) valueObject;
// Extract fields
Long id = value.getInt64("id");
String name = value.getString("name");
String email = value.getString("email");
// Write to external system
writeToDatabase(id, name, email);
} else {
// Handle schemaless data (e.g., Map<String, Object>)
Map<String, Object> value = (Map<String, Object>) valueObject;
// ...
}
}
}
Schema Types and Field Extraction
| Schema Type | Java Type | Extraction Method |
|---|---|---|
Schema.INT32_SCHEMA |
Integer |
value.getInt32("field") |
Schema.INT64_SCHEMA |
Long |
value.getInt64("field") |
Schema.STRING_SCHEMA |
String |
value.getString("field") |
Schema.BOOLEAN_SCHEMA |
Boolean |
value.getBoolean("field") |
Schema.FLOAT64_SCHEMA |
Double |
value.getFloat64("field") |
Timestamp.SCHEMA |
java.util.Date |
value.get("field") (returns Date) |
Date.SCHEMA |
java.util.Date |
value.get("field") (returns Date) |
Example: Database Insert with Struct
@Override
public void put(Collection<SinkRecord> records) {
try {
PreparedStatement stmt = connection.prepareStatement(
"INSERT INTO users (id, name, email, created_at) VALUES (?, ?, ?, ?)"
);
for (SinkRecord record : records) {
Struct value = (Struct) record.value();
// Extract fields with type safety
Long id = value.getInt64("id");
String name = value.getString("name");
String email = value.getString("email");
java.util.Date createdAt = (java.util.Date) value.get("created_at");
// Set parameters
stmt.setLong(1, id);
stmt.setString(2, name);
stmt.setString(3, email);
stmt.setTimestamp(4, new java.sql.Timestamp(createdAt.getTime()));
stmt.addBatch();
}
stmt.executeBatch();
connection.commit();
} catch (SQLException e) {
throw new ConnectException("Failed to write to database", e);
}
}
Handling Optional Fields
Scenario: Schema defines optional fields (e.g., email is nullable).
Schema valueSchema = SchemaBuilder.struct()
.field("id", Schema.INT64_SCHEMA)
.field("name", Schema.STRING_SCHEMA)
.field("email", Schema.OPTIONAL_STRING_SCHEMA) // Optional
.build();
Extraction:
Struct value = (Struct) record.value();
Long id = value.getInt64("id");
String name = value.getString("name");
String email = value.getString("email"); // May be null
// Handle null
if (email == null) {
email = "unknown@example.com";
}
Dynamic Schema Handling
Problem: Schema changes at runtime (new fields added).
Solution: Iterate over schema fields dynamically:
Schema valueSchema = record.valueSchema();
Struct value = (Struct) record.value();
// Get all field names
for (Field field : valueSchema.fields()) {
String fieldName = field.name();
Schema.Type fieldType = field.schema().type();
// Extract value based on type
switch (fieldType) {
case STRING:
String stringValue = value.getString(fieldName);
break;
case INT64:
Long longValue = value.getInt64(fieldName);
break;
// ... handle other types
}
}
Use Case: Auto-evolving database schemas (e.g., auto.evolve=true in JDBC Sink).
Schemaless Data (JSON without Schema)
Scenario: Connector receives JSON data without schema (e.g., schemas.enable=false).
Example Record:
{"id": 123, "name": "Alice", "email": "alice@example.com"}
Extraction:
@Override
public void put(Collection<SinkRecord> records) {
for (SinkRecord record : records) {
// Value is a Map<String, Object>
Map<String, Object> value = (Map<String, Object>) record.value();
// Extract fields (with type casting)
Long id = ((Number) value.get("id")).longValue();
String name = (String) value.get("name");
String email = (String) value.get("email");
writeToDatabase(id, name, email);
}
}
Trade-off: No type safety, requires manual casting and validation.
Schema-Based vs. Schemaless Comparison
| Aspect | With Schema (Struct) | Without Schema (Map) |
|---|---|---|
| Type Safety | Strong (enforced by Schema) | Weak (manual casting) |
| Performance | Fast (pre-defined structure) | Slower (dynamic lookups) |
| Evolution | Supported (Schema Registry) | Manual (no compatibility checks) |
| Complexity | Higher (schema management) | Lower (simple JSON) |
| Best For | Production pipelines | Rapid prototyping |
Recommendation: Use Schema + Struct for production deployments to ensure data quality and evolution support.
Example: Nested Structs
Scenario: Record contains nested objects.
Schema:
Schema addressSchema = SchemaBuilder.struct()
.field("street", Schema.STRING_SCHEMA)
.field("city", Schema.STRING_SCHEMA)
.field("zip", Schema.STRING_SCHEMA)
.build();
Schema userSchema = SchemaBuilder.struct()
.field("id", Schema.INT64_SCHEMA)
.field("name", Schema.STRING_SCHEMA)
.field("address", addressSchema) // Nested struct
.build();
Extraction:
Struct value = (Struct) record.value();
Long id = value.getInt64("id");
String name = value.getString("name");
// Extract nested struct
Struct address = value.getStruct("address");
String street = address.getString("street");
String city = address.getString("city");
String zip = address.getString("zip");
Use Case: Complex data models (e.g., user profiles with nested addresses, order with line items).
Batch Writes and Buffering
Why Batch?
Single-record writes (e.g., one INSERT per record) are inefficient:
- High network overhead (many round-trips).
- Low throughput (database locks per write).
Batch writes (e.g., one bulk INSERT for 1000 records) are efficient:
- Fewer round-trips.
- Higher throughput (amortized locks).
Batching Strategies
1. Fixed-Size Batching
Strategy: Buffer N records, then write.
Example:
List<SinkRecord> buffer = new ArrayList<>();
@Override
public void put(Collection<SinkRecord> records) {
buffer.addAll(records);
if (buffer.size() >= batchSize) {
writeBatch(buffer);
buffer.clear();
}
}
Pros: Simple, predictable memory usage.
Cons: Last batch may be incomplete if task stops.
2. Time-Based Batching
Strategy: Buffer for T seconds, then write.
Example (requires background thread):
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@Override
public void start(Map<String, String> props) {
scheduler.scheduleAtFixedRate(() -> {
if (!buffer.isEmpty()) {
writeBatch(buffer);
buffer.clear();
}
}, flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS);
}
Pros: Lower latency (records aren't held indefinitely).
Cons: More complex (requires scheduler).
3. Hybrid Batching (Recommended)
Strategy: Write when buffer is full or every T seconds, whichever comes first.
Best Practice: Use Connect's built-in batching (controlled by consumer.max.poll.records) and write all records in put() at once.
Delivery Guarantees
At-Least-Once Delivery (Default)
By default, sink connectors provide at-least-once delivery:
- Connect calls
put(records). - Task writes records to external system.
- Connect commits Kafka consumer offsets.
Failure Scenario: If the task crashes after writing but before offset commit, records will be re-consumed and re-written (duplicates).
Achieving Exactly-Once (Idempotency)
Strategy: Use idempotent writes in the external system.
Option 1: Upsert (Database)
Example (PostgreSQL):
INSERT INTO users (id, name, email) VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email
Result: Duplicate writes are harmless (last write wins).
Option 2: Unique IDs (Elasticsearch)
Example:
IndexRequest request = new IndexRequest("users")
.id(String.valueOf(userId)) // Use user ID as document ID
.source(jsonMap);
Result: Duplicate writes overwrite the same document (idempotent).
Option 3: Deduplication (Application-Level)
Strategy: Track processed offsets in the external system and skip duplicates.
Example (Database):
CREATE TABLE processed_offsets (
topic VARCHAR,
partition INT,
offset BIGINT,
PRIMARY KEY (topic, partition)
);
Before Writing:
SELECT offset FROM processed_offsets WHERE topic = ? AND partition = ?;
If offset > last processed, write and update processed_offsets.
External System Integration Patterns
Pattern 1: Batch Database Inserts (JDBC)
Use Case: Write records to a relational database.
Strategy: Use PreparedStatement.addBatch() for bulk inserts.
Example:
for (SinkRecord record : records) {
Struct value = (Struct) record.value();
preparedStatement.setLong(1, value.getInt64("id"));
preparedStatement.setString(2, value.getString("name"));
preparedStatement.addBatch();
}
preparedStatement.executeBatch();
connection.commit();
Best Practice: Use upsert mode for idempotency.
Pattern 2: Bulk Indexing (Elasticsearch)
Use Case: Index documents in Elasticsearch.
Strategy: Use Elasticsearch Bulk API.
Example:
BulkRequest bulkRequest = new BulkRequest();
for (SinkRecord record : records) {
Struct value = (Struct) record.value();
Map<String, Object> jsonMap = structToMap(value);
IndexRequest indexRequest = new IndexRequest("users")
.id(String.valueOf(value.getInt64("user_id")))
.source(jsonMap);
bulkRequest.add(indexRequest);
}
BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
throw new ConnectException("Bulk indexing failed: " + bulkResponse.buildFailureMessage());
}
Best Practice: Use document IDs for idempotency.
Pattern 3: File Writes (S3, HDFS)
Use Case: Write records to files in S3 or HDFS.
Strategy: Buffer records, write to local file, upload to S3 on rotation.
Example:
BufferedWriter writer = new BufferedWriter(new FileWriter("/tmp/output.json"));
for (SinkRecord record : records) {
String json = recordToJson(record);
writer.write(json);
writer.newLine();
}
writer.close();
// Upload to S3
s3Client.putObject("my-bucket", "output-" + System.currentTimeMillis() + ".json", new File("/tmp/output.json"));
Best Practice: Use partition/offset in file naming for idempotency.
Pattern 4: REST API Writes
Use Case: POST records to a REST API.
Strategy: Batch records into JSON array, send bulk POST request.
Example:
JSONArray jsonArray = new JSONArray();
for (SinkRecord record : records) {
Struct value = (Struct) record.value();
JSONObject json = new JSONObject();
json.put("id", value.getInt64("id"));
json.put("name", value.getString("name"));
jsonArray.put(json);
}
HttpPost request = new HttpPost("https://api.example.com/bulk");
request.setEntity(new StringEntity(jsonArray.toString()));
HttpResponse response = httpClient.execute(request);
if (response.getStatusLine().getStatusCode() != 200) {
throw new ConnectException("API write failed: " + response.getStatusLine());
}
Best Practice: Use retries for transient failures (network timeouts).
Example: Elasticsearch Sink Connector
Scenario
Write user records from Kafka to Elasticsearch.
Implementation
public class ElasticsearchSinkTask extends SinkTask {
private RestHighLevelClient client;
private String indexName;
@Override
public void start(Map<String, String> props) {
String host = props.get("connection.url");
this.indexName = props.get("index.name");
// Initialize Elasticsearch client
this.client = new RestHighLevelClient(
RestClient.builder(HttpHost.create(host))
);
}
@Override
public void put(Collection<SinkRecord> records) {
BulkRequest bulkRequest = new BulkRequest();
for (SinkRecord record : records) {
Struct value = (Struct) record.value();
Map<String, Object> document = new HashMap<>();
document.put("id", value.getInt64("id"));
document.put("name", value.getString("name"));
document.put("email", value.getString("email"));
IndexRequest indexRequest = new IndexRequest(indexName)
.id(String.valueOf(value.getInt64("id")))
.source(document);
bulkRequest.add(indexRequest);
}
try {
BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
throw new ConnectException("Bulk indexing failed: " + bulkResponse.buildFailureMessage());
}
} catch (IOException e) {
throw new ConnectException("Failed to index documents", e);
}
}
@Override
public void stop() {
try {
if (client != null) {
client.close();
}
} catch (IOException e) {
// Log error
}
}
@Override
public String version() {
return "1.0.0";
}
}
Offset Commit Control (Advanced)
Default Behavior
Connect automatically commits offsets after put() completes without throwing an exception.
Custom Commit Logic
For advanced use cases, you can override preCommit() to control offset commits:
@Override
public Map<TopicPartition, OffsetAndMetadata> preCommit(
Map<TopicPartition, OffsetAndMetadata> currentOffsets
) {
// Custom logic: only commit if external system confirms write
if (externalSystemConfirmsWrite()) {
return currentOffsets; // Commit these offsets
} else {
return Collections.emptyMap(); // Don't commit (will retry)
}
}
Use Case: Transactional writes where Kafka offsets must be committed atomically with external system writes.
Key Takeaways
SinkTask.put()is the core method—process batch of records, write to external system.- Batch writes are essential for performance (use bulk APIs, prepared statements).
- At-least-once delivery by default; achieve exactly-once via idempotent writes (upsert, unique IDs).
- Offset commits happen automatically after
put()completes successfully. - External integration: JDBC batch inserts, Elasticsearch bulk API, S3 file writes, REST API posts.
What's Next?
- Module 7.4 explains testing and packaging custom connectors.
- Module 6.1 reviews error handling and retries (relevant for sink failures).
- Module 2.1 reviews Source vs. Sink semantics (cross-reference).
Further Reading
End of Module 7.3