Guide · DynamoDB Streams Advanced
DynamoDB Streams Kinesis Adapter — KCL Distributed Processing for MCP Servers
The DynamoDB Streams Kinesis Adapter lets you use the Kinesis Client Library (KCL) to read from DynamoDB Streams — giving you the same distributed worker coordination, automatic checkpointing, and shard failover that KCL provides for Kinesis Data Streams. DynamoDB Streams exposes the same GetShardIterator and GetRecords API as Kinesis, and the adapter bridges the two by implementing the Kinesis interfaces against the DynamoDB Streams endpoints. The result: you can run multiple worker processes (ECS tasks, EC2 instances, Lambda containers) that share shard assignments via a DynamoDB lease table, automatically rebalance when workers join or leave, and checkpoint at the record level for exactly-once processing semantics per worker. For AliveMCP, the KCL adapter pattern is most valuable when Lambda ESM's single-consumer-per-shard model becomes a bottleneck — for example, when a probe state table with 50+ partitions generates 500+ DynamoDB Streams shards, and Lambda ESM's invocation overhead per-shard becomes more expensive than running a fleet of long-lived KCL workers on ECS Fargate.
TL;DR
The DynamoDB Streams Kinesis Adapter wraps DynamoDB Streams behind the Kinesis API surface so KCL 2.x can consume it. Worker coordination is via a DynamoDB lease table (one row per shard, one owner per row at a time). Shards in DynamoDB Streams have a 24-hour lifetime — after expiry, KCL must follow the parent-child shard sequence to read from the shard's successor. Unlike Kinesis, DynamoDB Streams shards cannot be extended by changing retention — the 24-hour window is fixed. Choose Lambda ESM for ≤50 shards or bursty workloads; choose KCL adapter for ≥50 shards with continuous high-throughput write rates where per-shard Lambda invocation overhead dominates.
Architecture: how the adapter bridges DynamoDB Streams to KCL
KCL uses three Kinesis Data Streams API calls to consume records: ListShards, GetShardIterator, and GetRecords. DynamoDB Streams exposes equivalent calls under different names (DescribeStream, GetShardIterator, GetRecords), and the adapter translates between them transparently.
| KCL operation | Kinesis API | DynamoDB Streams equivalent (adapter translates) |
|---|---|---|
| List available shards | ListShards | DescribeStream (paginated; returns all shards in the stream) |
| Get iterator for a shard | GetShardIterator | GetShardIterator (same name; different ARN format) |
| Read records from shard | GetRecords | GetRecords (returns DynamoDB change records; adapter translates to Kinesis Record format) |
| Check stream health | DescribeStreamSummary | DescribeStream (adapter parses StreamStatus field) |
The adapter maps DynamoDB Streams records to KCL's Record interface by serializing the DynamoDB Streams record (including eventName, dynamodb.NewImage, dynamodb.OldImage, and dynamodb.Keys) as JSON in the KCL record's data payload. Your ShardRecordProcessor deserializes this JSON to access the DynamoDB change event.
// Gradle dependency for the adapter
// build.gradle
dependencies {
implementation 'com.amazonaws:dynamodbstreams-kinesis-adapter:1.6.0'
implementation 'software.amazon.kinesis:amazon-kinesis-client:2.5.0'
implementation 'com.amazonaws:aws-java-sdk-dynamodb:1.12.500'
}
// Java: configure KCL 2.x with DynamoDB Streams Kinesis Adapter
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.streamsadapter.AmazonDynamoDBStreamsAdapterClient;
import software.amazon.kinesis.coordinator.Scheduler;
import software.amazon.kinesis.common.ConfigsBuilder;
// 1. Create the adapter client wrapping DynamoDB Streams
AmazonDynamoDB streamsClient = AmazonDynamoDBClientBuilder.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(
"https://streams.dynamodb.us-east-1.amazonaws.com", "us-east-1"))
.build();
AmazonDynamoDBStreamsAdapterClient adapterClient = new AmazonDynamoDBStreamsAdapterClient(streamsClient);
// 2. Standard KCL 2.x setup, passing the adapter client as the Kinesis client
ConfigsBuilder configsBuilder = new ConfigsBuilder(
streamArn, // DynamoDB Streams ARN (not a Kinesis stream ARN)
"alivemcp-probe-processor", // Application name — becomes DynamoDB lease table name
adapterClient, // The adapter, implementing AmazonKinesis interface
dynamoDBForLeaseTable, // Separate DynamoDB client for the KCL lease table
cloudWatchClient,
UUID.randomUUID().toString(), // Worker ID — unique per worker process
new ProbeStateRecordProcessorFactory()
);
Scheduler scheduler = new Scheduler(
configsBuilder.checkpointConfig(),
configsBuilder.coordinatorConfig(),
configsBuilder.leaseManagementConfig(),
configsBuilder.lifecycleConfig(),
configsBuilder.metricsConfig(),
configsBuilder.processorConfig(),
configsBuilder.retrievalConfig()
);
// Runs in a background thread; call scheduler.startGracefulShutdown() to stop
Thread schedulerThread = new Thread(scheduler);
schedulerThread.start();
ShardRecordProcessor implementation for DynamoDB Streams
Your ShardRecordProcessor receives KCL Record objects whose data payload is the serialized DynamoDB Streams change event. The processor must deserialize this JSON and dispatch based on eventName:
import software.amazon.kinesis.processor.ShardRecordProcessor;
import software.amazon.kinesis.lifecycle.events.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
public class ProbeStateRecordProcessor implements ShardRecordProcessor {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void initialize(InitializationInput initializationInput) {
// Called once when this worker takes ownership of a shard
System.out.println("Initialized shard: " + initializationInput.shardId());
}
@Override
public void processRecords(ProcessRecordsInput processRecordsInput) {
for (var record : processRecordsInput.records()) {
try {
// KCL record data is UTF-8 JSON of the DynamoDB Streams record
String json = StandardCharsets.UTF_8.decode(record.data()).toString();
Map<String, Object> streamRecord = MAPPER.readValue(json, Map.class);
String eventName = (String) streamRecord.get("eventName");
Map<String, Object> dynamoDb = (Map<String, Object>) streamRecord.get("dynamodb");
Map<String, Object> newImage = (Map<String, Object>) dynamoDb.get("NewImage");
if ("INSERT".equals(eventName) || "MODIFY".equals(eventName)) {
Map<String, Object> statusAttr = (Map<String, Object>) newImage.get("status");
String status = (String) statusAttr.get("S");
if ("DEGRADED".equals(status) || "DOWN".equals(status)) {
triggerAlert(dynamoDb, status);
}
}
} catch (Exception e) {
// Log and continue — do NOT rethrow; rethrowing halts shard processing
System.err.println("Failed to process record: " + e.getMessage());
}
}
// Checkpoint after processing the batch — at-least-once semantics
// (records may be re-delivered if worker fails before next checkpoint)
try {
processRecordsInput.checkpointer().checkpoint();
} catch (Exception e) {
System.err.println("Checkpoint failed: " + e.getMessage());
}
}
@Override
public void leaseLost(LeaseLostInput leaseLostInput) {
// Another worker took ownership of this shard — clean up resources
// Do NOT checkpoint here (we no longer own the lease)
}
@Override
public void shardEnded(ShardEndedInput shardEndedInput) {
// Shard has expired (24-hour DynamoDB Streams shard lifetime reached)
// MUST checkpoint SHARD_END or child shards will not be processed
try {
shardEndedInput.checkpointer().checkpoint();
} catch (Exception e) {
System.err.println("SHARD_END checkpoint failed: " + e.getMessage());
}
}
@Override
public void shutdownRequested(ShutdownRequestedInput shutdownRequestedInput) {
// Graceful shutdown: checkpoint current position before worker exits
try {
shutdownRequestedInput.checkpointer().checkpoint();
} catch (Exception e) {
System.err.println("Shutdown checkpoint failed: " + e.getMessage());
}
}
}
DynamoDB Streams shard lifecycle — 24-hour windows
DynamoDB Streams shards have a fixed 24-hour lifetime, unlike Kinesis Data Streams shards (which remain active indefinitely unless the stream is resharded). When a shard expires, all records in that shard are still readable for 24 hours after the shard's first record was written — but the shard stops accepting new records. A new "child" shard is created to receive new changes for the same DynamoDB partition.
| Shard event | KCL behavior | Your processor must |
|---|---|---|
| Shard created (new DynamoDB partition) | KCL discovers via ListShards polling; creates new lease in lease table | Nothing — KCL handles automatically |
| Shard reaches 24-hour lifetime | KCL calls shardEnded() on the processor owning that shard | Call checkpointer.checkpoint() with SHARD_END sequence — required before child shard starts |
| Child shard starts (after parent expires) | KCL discovers child shard; waits for parent's SHARD_END checkpoint before starting child | Nothing — KCL handles ordering; but if parent SHARD_END checkpoint is not called, child never starts |
| DynamoDB table partition split | One parent shard → two child shards; KCL detects parentShardIds in child shard metadata | Call shardEnded() checkpoint on parent before either child can start |
| Worker process crashes mid-shard | KCL detects lease expiry (10-second failoverTimeMillis); another worker claims the shard and resumes from last checkpoint | Idempotent record processing — the last batch may be re-delivered to the new owner |
The most critical operational requirement: always call checkpointer.checkpoint() in shardEnded(). If you do not checkpoint the SHARD_END sentinel sequence number, KCL considers the parent shard incomplete and will never start processing the child shard. This causes your processor to silently fall behind and stop receiving new changes for all partitions that underwent a shard transition — which happens to every DynamoDB partition every 24 hours.
Lambda ESM vs KCL adapter: decision framework
For most MCP server monitoring deployments, Lambda ESM is the right choice. The KCL adapter adds operational complexity (managing a long-lived process fleet, lease table maintenance, JVM heap tuning) that is only justified at scale:
| Factor | Lambda ESM preferred | KCL adapter preferred |
|---|---|---|
| DynamoDB Streams shard count | <50 shards (small to medium tables) | >50 shards (large, heavily partitioned tables) |
| Write rate to DynamoDB table | Bursty or low (0-10k writes/min) | High and continuous (>10k writes/min sustained) |
| Processing latency requirement | Alert within 5-30s of write (Lambda ESM p99 ~5s) | Alert within 1-2s (KCL long-polling ~1s) |
| Multiple independent consumers | One consumer only (Lambda ESM occupies the stream) | Multiple independent KCL applications can share one stream |
| Processing language | Any Lambda runtime (Node.js, Python, Go, etc.) | Java (primary KCL language); Python via MultiLangDaemon (adds JVM dependency) |
| Operational overhead | Serverless — no fleet management | ECS/EC2 fleet + lease table + JVM heap monitoring |
| Cost profile | Per-invocation (variable with write rate) | Fixed (ECS Fargate task cost, always-on) |
For AliveMCP monitoring 100,000 MCP endpoints with 60-second probe intervals (~1,667 writes/second), a DynamoDB probe state table will have ~100-200 active partitions = ~100-200 active DynamoDB Streams shards. Lambda ESM handles this well: 100 concurrent Lambda invocations (one per shard) at 5 ms average execution time per batch = negligible compute cost. The KCL adapter becomes compelling if the probe result fanout (writing enriched probe events to multiple downstream services) requires stateful aggregation that doesn't fit in Lambda's 15-minute timeout.
Complete failure mode reference
| Symptom | Root cause | Detection | Fix |
|---|---|---|---|
| Child shards never processed — processor falls behind indefinitely | shardEnded() was not called or checkpoint was not committed — KCL will not start child shards until parent SHARD_END is checkpointed | KCL metrics show millis_behind_latest increasing for affected shards; lease table shows parent shard with no SHARD_END checkpoint row | Fix shardEnded() to always call checkpointer.checkpoint() without catching and swallowing the checkpoint exception; restart the worker to trigger re-processing |
| Lease table throttled — KCL worker delays and retries | Default lease table is provisioned with 10 RCU/WCU — insufficient for large shard counts (>200 shards with 10s lease renewal cycle = 20 writes/second) | KCL logs show ProvisionedThroughputExceededException from lease table; CloudWatch shows ConsumedWriteCapacityUnits exceeding provisioned | Change lease table to on-demand billing mode; or increase provisioned WCU to (shard_count / 10s renewal) × 2 for headroom |
| Duplicate records processed after worker restart | At-least-once semantics — KCL resumes from last checkpoint, which was before the records that were processed but not yet checkpointed at crash time | Downstream alert store shows duplicate alerts for same probe state transition within 30s | Make probe state change processing idempotent: check current alert state in DynamoDB before sending; use probeId + timestamp as idempotency key |
| Worker cannot connect to DynamoDB Streams endpoint | VPC without internet gateway or DynamoDB Streams VPC endpoint; worker needs access to both dynamodb.region.amazonaws.com (lease table) and streams.dynamodb.region.amazonaws.com (the stream itself) | Worker logs show Connection refused or timeout to streams.dynamodb endpoint; lease table writes succeed but stream reads fail | Add VPC Interface endpoint for DynamoDB Streams (com.amazonaws.region.dynamodb-streams) in addition to the standard DynamoDB endpoint; or add NAT gateway for internet access |
| KCL application name conflict — two applications sharing lease table | Two KCL applications with the same application name share the same DynamoDB lease table — workers steal leases from each other | Shard assignments unstable; workers re-assign shards every 10s; KCL metrics show high lease steal rate | Each KCL application must have a unique application name; this becomes the DynamoDB lease table name — treat it as a globally unique identifier per consumer |