Guide · MSK Consumer Groups · Parallelism

MSK Consumer Groups for MCP Probe Workers

Kafka consumer groups distribute partition processing across multiple workers, enabling horizontal scaling of MCP probe result processing. For AliveMCP's alert pipeline — where each consumer must maintain per-server state to detect up/down transitions — partition assignment strategy, rebalance behavior, and consumer lag directly affect alert delivery latency. This guide covers the KafkaJS consumer group configuration, partition assignment strategies, cooperative rebalancing, consumer lag monitoring, and graceful shutdown for ECS-hosted probe workers.

Consumer group fundamentals for MCP probe pipelines

A consumer group is a set of Kafka consumers that collectively consume a topic. Each partition is assigned to exactly one consumer at a time. When consumers join or leave the group, Kafka re-assigns partitions — a process called rebalancing.

For the MCP probe result pipeline: the mcp-probe-results topic has 60 partitions. With 6 alert-processor consumers in one group, each consumer owns 10 partitions — 10 partitions × ~33 servers/partition = ~330 servers per consumer. The consumer tracks each server's last known status in memory keyed by server slug. When a new probe result arrives with a different status, the consumer triggers an alert.

This stateful pattern requires that all probes for a given server always route to the same consumer. Since Kafka routes by partition key (the server slug hash), and partition assignment within a group gives each consumer a stable set of partitions, this holds as long as no rebalance occurs. During a rebalance, partitions move between consumers — requiring the new owner to reload state from the DynamoDB source of truth before processing new messages.

Multiple consumer groups: independent pipelines from one topic

// Three consumer groups, each consuming all 60 partitions independently:
// alert-processor-group — stateful up/down transition detection → webhook delivery
// archive-writer-group  — all probe results → S3 + MSK Connect sink (see MSK Connect guide)
// analytics-group       — aggregate metrics computation (p50/p95 latency, registry health score)

// Each group maintains its own committed offset — progress is independent
// Adding a third group does NOT affect the alert processor's throughput or lag

Consumer groups are free to create (no cost, no capacity allocation). Add a new consumer group to add a new pipeline without affecting existing groups. The only shared resource is topic retention: keep topics long enough for the slowest consumer group to process at its own pace.

KafkaJS consumer group configuration

import { Kafka, logLevel } from 'kafkajs';

const consumer = kafka.consumer({
  groupId: 'alert-processor-group',
  sessionTimeout:       45_000,  // broker declares consumer dead after 45s of no heartbeat
  heartbeatInterval:    10_000,  // send heartbeat every 10s (must be < sessionTimeout / 3)
  maxWaitTimeInMs:      5_000,   // max time to wait for batch to accumulate before returning
  minBytes:             1,       // min bytes to fetch before returning (1 = return immediately)
  maxBytes:             10_485_760, // max 10MB per fetch request
  partitionAssigners: [PartitionAssigners.roundRobin], // see assignor comparison below
});

await consumer.connect();
await consumer.subscribe({ topics: ['mcp-probe-results'], fromBeginning: false });

// In-memory state: last known status per server slug
const serverStatus = new Map<string, string>();

await consumer.run({
  autoCommit:              true,
  autoCommitInterval:      5_000,   // commit offsets every 5 seconds
  autoCommitThreshold:     100,     // or every 100 messages (whichever comes first)
  eachBatchAutoResolve:    true,

  async eachMessage({ topic, partition, message }) {
    const payload = JSON.parse(message.value!.toString());
    const { serverSlug, status, checkedAt, responseTimeMs } = payload;

    const lastStatus = serverStatus.get(serverSlug);

    if (lastStatus !== undefined && lastStatus !== status) {
      // Status transition — fire alert
      await sendAlert({
        type:           status === 'down' ? 'DOWN' : status === 'up' ? 'RECOVERED' : 'DEGRADED',
        serverSlug,
        previousStatus: lastStatus,
        currentStatus:  status,
        checkedAt,
        responseTimeMs,
      });
    }

    serverStatus.set(serverSlug, status);  // update in-memory state
  },
});

Session timeout and heartbeat interval relationship

The Kafka broker declares a consumer dead when it hasn't received a heartbeat within sessionTimeout. KafkaJS sends heartbeats on a background timer at heartbeatInterval. The rule: heartbeatInterval < sessionTimeout / 3. With sessionTimeout: 45_000 and heartbeatInterval: 10_000, the broker allows 4+ missed heartbeats before triggering a rebalance — tolerating 30+ seconds of slow message processing before declaring the consumer dead.

Increase sessionTimeout if your alert processing (webhook delivery, DynamoDB writes) sometimes takes more than 10 seconds. Do not decrease heartbeatInterval below 3,000ms — too-frequent heartbeats increase broker load without improving failure detection speed.

Partition assignment strategies

KafkaJS supports multiple partition assignors that determine how partitions are distributed when consumers join or leave:

AssignorBehaviorRebalance impactBest for
RoundRobinPartitions distributed evenly round-robin across consumersFull rebalance — all partitions re-assignedStateless consumers where assignment history doesn't matter
RangePartitions 0–N/C assigned to consumer 0, N/C+1–2N/C to consumer 1, etc.Full rebalance — all re-assignedDefault KafkaJS; simple but uneven for unequal partition counts
CooperativeStickyAssignorMinimizes partition movement; only revokes partitions from over-assigned consumersIncremental — only affected partitions moveStateful consumers that maintain per-partition state (probe pipeline)

For the stateful probe alert processor, CooperativeStickyAssignor is strongly preferred. When a new consumer joins (scale-out), only the partitions that need to move for load balance are revoked — not all 60 partitions. Each consumer keeps its partitions (and in-memory state) unless Kafka explicitly revokes them. This eliminates the full state reload on every scale event.

import { PartitionAssigners } from 'kafkajs';

const consumer = kafka.consumer({
  groupId: 'alert-processor-group',
  partitionAssigners: [PartitionAssigners.cooperative],  // CooperativeStickyAssignor
  // ...
});

State reload on partition assignment

Even with cooperative rebalancing, new partitions will occasionally be assigned (scale-out, new deployment). When a new partition arrives, the consumer's in-memory serverStatus map doesn't have entries for those servers yet. The first probe result for each server will have lastStatus === undefined, so no alert fires — correct behavior, since we don't know the previous state. Subsequent probes correctly detect transitions.

For faster state recovery, preload from DynamoDB on partition assignment:

consumer.on(consumer.events.GROUP_JOIN, async ({ payload }) => {
  // Payload includes memberAssignment — the partitions this consumer now owns
  // Query DynamoDB StatusIndex for servers assigned to these partitions
  // (requires knowing which server slugs hash to which partitions)
  // Simpler: on assignment, query ALL servers with status='down' and seed serverStatus
  const downServers = await queryDynamoStatusIndex('down');
  for (const server of downServers) {
    serverStatus.set(server.serverSlug, 'down');
  }
});

Consumer lag monitoring with CloudWatch

Consumer lag — the number of unconsumed messages between the consumer's committed offset and the topic's latest offset — is the primary health metric for the probe pipeline. A growing lag means probes are completing faster than alerts are firing; alert latency is increasing.

MSK publishes consumer lag to CloudWatch when enhanced monitoring is set to PER_TOPIC_PER_PARTITION:

// CloudWatch metric: kafka.consumer_group.offset_lag
// Dimensions: ClusterName, ConsumerGroup, Topic, Partition

// CDK alarm: alert if any partition lag exceeds 2 minutes of probes
new cloudwatch.Alarm(this, 'ConsumerLagAlarm', {
  alarmName: 'mcp-probe-consumer-lag',
  metric: new cloudwatch.Metric({
    namespace:  'AWS/Kafka',
    metricName: 'OffsetLag',
    dimensionsMap: {
      ClusterName:   clusterName,
      ConsumerGroup: 'alert-processor-group',
      Topic:         'mcp-probe-results',
    },
    statistic: 'Maximum',       // Maximum across all partitions — any lagging partition triggers
    period:    cdk.Duration.minutes(1),
  }),
  threshold:          120,      // 120 unconsumed messages ≈ 2 minutes of probes at 1/min cadence
  evaluationPeriods:  3,        // 3 consecutive minutes before alarm
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  alarmDescription:  'Alert processor falling behind — probe alert delivery delayed',
});

Lag spike patterns and their causes

Lag patternLikely causeRemediation
Sudden spike on all partitions, then recoversRebalance — all partitions paused during rebalanceNormal with full rebalance; switch to CooperativeStickyAssignor to reduce
Slow monotonic growth on specific partitionsOne consumer is slow (webhook delivery timeout, DynamoDB throttle)Tune downstream timeouts; add consumer concurrency; check DynamoDB on-demand capacity
Lag drops to zero then immediately grows againConsumer restarting in crash loop; re-reading from committed offset each timeFix crash cause; check ECS service events for OOM or unhandled promise rejections
Lag constant but non-zeroProducer throughput exceeds consumer throughput; consumer count insufficientAdd consumer replicas (up to partition count max)
Lag stuck at exact offset, consumer appears activePoison message — consumer processing it, failing, retrying infinitelyCheck consumer logs for repeated error; route to DLQ or skip via offset reset

Graceful shutdown for ECS probe workers

ECS sends SIGTERM before stopping a task. A consumer that exits without calling consumer.disconnect() leaves its partitions unassigned until the session timeout expires (45 seconds). During that window, those partitions are not being processed — probe results accumulate. Graceful shutdown reduces this window to near-zero:

// Graceful SIGTERM handler for ECS tasks
const gracefulShutdown = async (signal: string) => {
  console.log(`Received ${signal} — starting graceful shutdown`);
  isShuttingDown = true;

  // Stop accepting new messages
  await consumer.pause([{ topic: 'mcp-probe-results' }]);

  // Wait for in-flight message processing to complete (max 10s)
  await new Promise(resolve => setTimeout(resolve, 10_000));

  // Commit current offsets and leave consumer group
  // Kafka triggers immediate rebalance to reassign partitions to remaining consumers
  await consumer.disconnect();

  console.log('Consumer disconnected cleanly');
  process.exit(0);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT',  () => gracefulShutdown('SIGINT'));

The consumer.disconnect() call triggers a LeaveGroup request to the broker, which immediately initiates a rebalance to reassign partitions to remaining consumers. Without this, the broker waits for the session timeout (45 seconds) before declaring the consumer dead and triggering rebalance — 45 seconds of unprocessed probes.

How AliveMCP monitors consumer group health

AliveMCP tracks consumer group health as part of the MSK probe pipeline monitoring. In addition to the lag alarm described above, AliveMCP monitors:

Related guides