aws_kinesis: add enhanced fan-out support and poll_period - #4724
Conversation
Adds two ways to avoid the shared 5 GetRecords/sec/shard Kinesis limit: - A poll_period field bounding the rate of GetRecords calls per shard, validated against lease_period and warned against commit_period. The gate is serviced in bounded slices so checkpointing, lease renewal, and batch delivery are never starved. - An enhanced_fan_out configuration block that registers a dedicated stream consumer (auto-registered in parallel per stream, never deregistered) and consumes shards via SubscribeToShard HTTP/2 push with resubscription at the continuation sequence and a 1s floor between subscribe attempts. Shard-handoff contention (ResourceInUseException) is absorbed by background retries so shard claims are never blocked or stranded, sequences rejected by Kinesis fall back to the oldest retained record at any point in the subscription lifecycle, latest-mode starts are anchored to a fixed timestamp so resubscribe retries cannot skip records, and the retry backoff escalates only while no events are delivered. The consumer loop's record-fetch step is extracted behind a shardRecordSource interface; the polling implementation preserves the previous behavior exactly, except that a stored sequence that has aged out of the retention window now falls back to the oldest retained record instead of stalling. Checkpointing, shard balancing, and ack semantics are unchanged. Both features default off.
| // Bound how long a single Fetch may wait internally, whether that's the | ||
| // enhanced fan-out push wait or the poll_period gate, so the consumer loop | ||
| // keeps servicing its commit timer well within each commit period. | ||
| fetchTimeout := time.Second |
There was a problem hiding this comment.
fetchTimeout is derived only from commit_period, but a blocking Fetch also delays the timed batch flush, not just checkpointing.
In runConsumer the pull happens at the top of the loop body, before the select that services nextTimedBatchChan:
connect/internal/impl/aws/kinesis/input.go
Lines 559 to 570 in 2910f76
So once pending is drained and nextPullChan is unblocked, the loop calls source.Fetch, which for the EFO source blocks up to fetchTimeout (1s by default) and for the gated polling source sleeps up to maxGateWait. An already-expired nextTimedBatchChan is only observed after that returns, so a partial batch configured with e.g. batching.period: 100ms is flushed up to ~1s late whenever the shard is idle. Before this change getRecords was a non-long-polling call that returned in milliseconds, so this is a new latency regression on both new code paths.
Suggested fix: bound fetchTimeout by the configured batching period as well as commit_period/2 (or have the loop re-check nextTimedBatchChan before entering a blocking fetch).
There was a problem hiding this comment.
Good catch — fixed in c30536f. The fetch wait is now bounded by shardFetchWaitBound = min(1s, commit_period/2, batching.period/2) with a 5ms floor, applied to both the EFO fetch park and the poll_period gate, so timed batch flushes are serviced within half the batching period.
| var ( | ||
| efoConsumerActiveTimeout = time.Minute | ||
| efoConsumerPollInterval = time.Second | ||
| // efoResubscribeFloor is the minimum spacing enforced between successive | ||
| // SubscribeToShard calls for a given shard/consumer, matching the API's | ||
| // one-call-per-second limit. It also seeds the resubscribe backoff's | ||
| // initial interval. | ||
| efoResubscribeFloor = time.Second | ||
| ) |
There was a problem hiding this comment.
These are user-facing timing knobs (how long Connect waits for a newly registered consumer to reach ACTIVE, and how often it polls DescribeStreamConsumer) but they're hardcoded package vars that are only mutable from tests.
The project Go patterns state under Configurable Time Parameters: "Every time-related value (timeouts, backoffs, intervals, retry delays) must be exposed as a YAML-configurable field. Do not hardcode durations." (.claude/agents/godev.md)
efoResubscribeFloor is arguably fine since it encodes a fixed AWS API limit, but efoConsumerActiveTimeout (and the boff.MaxInterval = time.Second * 30 in run) are policy choices a user may need to tune — a 1-minute cap on consumer activation will fail Connect outright on a slow registration. Consider exposing them under the enhanced_fan_out object.
There was a problem hiding this comment.
Addressed across c30536f and d45bdff: enhanced_fan_out.consumer_activation_timeout (default 1m) and enhanced_fan_out.max_resubscribe_interval (default 30s, validated >= the 1s floor) are now advanced fields, and the fetch park is no longer a literal — it's derived as min(1s, commit_period/2, batching.period/2). Deliberately kept internal: the 1s resubscribe floor (encodes the SubscribeToShard 1-call/sec/shard/consumer API limit — commented as such) and the DescribeStreamConsumer poll cadence (an implementation detail inside the configurable activation timeout, also commented). Remaining literals are named constants.
| // Overridable in tests. | ||
| var ( | ||
| efoConsumerActiveTimeout = time.Minute | ||
| efoConsumerPollInterval = time.Second | ||
| // efoResubscribeFloor is the minimum spacing enforced between successive | ||
| // SubscribeToShard calls for a given shard/consumer, matching the API's | ||
| // one-call-per-second limit. It also seeds the resubscribe backoff's | ||
| // initial interval. | ||
| efoResubscribeFloor = time.Second | ||
| ) | ||
|
|
There was a problem hiding this comment.
The new enhanced fan-out path introduces several hardcoded durations that are only overridable from tests, not from YAML:
efoConsumerActiveTimeout = time.Minute— this one is user-visible: it bounds how longConnectblocks per stream waiting for a newly registered consumer to reachACTIVE, and on a slow/throttled account it decides whether startup fails or succeeds.efoConsumerPollInterval = time.Second— theDescribeStreamConsumerpoll cadence during that wait.boff.MaxInterval = time.Second * 30inrun(input_efo.go#L717-L725) — the resubscribe backoff ceiling.fetchTimeout := time.SecondinnewShardRecordSource(input.go#L676-L689) — how long a singleFetchparks waiting for a pushed batch.
This conflicts with the project Go patterns in .claude/agents/godev.md, Configurable Time Parameters:
Every time-related value (timeouts, backoffs, intervals, retry delays) must be exposed as a YAML-configurable field. Do not hardcode durations.
Suggested fix: expose at least the consumer-activation timeout (and ideally the poll interval and resubscribe backoff ceiling) as fields under the enhanced_fan_out object, with the current values as static defaults, and derive fetchTimeout from a configurable field rather than a literal. efoResubscribeFloor is reasonable to leave fixed since it mirrors the AWS one-call-per-second-per-shard API limit — worth a short comment saying so.
There was a problem hiding this comment.
Addressed across c30536f and d45bdff: enhanced_fan_out.consumer_activation_timeout (default 1m) and enhanced_fan_out.max_resubscribe_interval (default 30s, validated >= the 1s floor) are now advanced fields, and the fetch park is no longer a literal — it's derived as min(1s, commit_period/2, batching.period/2). Deliberately kept internal: the 1s resubscribe floor (encodes the SubscribeToShard 1-call/sec/shard/consumer API limit — commented as such) and the DescribeStreamConsumer poll cadence (an implementation detail inside the configurable activation timeout, also commented). Remaining literals are named constants.
…activation timeout
| SequenceNumber: &continuation, | ||
| } | ||
| } | ||
| if streamErr != nil { |
There was a problem hiding this comment.
Resubscribe backoff is never advanced when a subscription ends without an error and without progress
boff.NextBackOff() is only consumed inside this if streamErr != nil branch, but the reset is gated on sawEvent (input_efo.go#L317-L325). The two conditions are independent, so the case the comment explicitly calls out is not actually covered:
consumereturns becausesub.Events()was closed,sawEvent == false(noSubscribeToShardEventwas ever delivered) andsub.Err() == nil.boffis neither reset nor advanced; the loop falls through to the top withsub == nil.waitForResubscribeFloorwaits onlyefoResubscribeFloor(1s), and the cycle repeats indefinitely at exactly the one-call-per-second-per-shardSubscribeToShardlimit.
The comment above states the intent as "a subscription that is accepted and then dies without delivering a single event would otherwise retry at the one-second floor forever, saturating the SubscribeToShard limit. Escalating instead also damps the subscription ping-pong two consumers can produce whilst a shard lease is being stolen." — the shard-steal ping-pong is precisely a case where the losing subscription can be terminated without a stream error, so it keeps hammering the floor rather than escalating.
Suggested fix: drive the escalation off progress rather than off the error, e.g. sleep boff.NextBackOff() whenever !sawEvent (and still on streamErr != nil), so a subscription that is accepted and dies with no records backs off as documented.
Relates to CONTRIBUTING.md §3.2.5 (excessive resource usage) and §3.1.4 (complete and correct implementation).
There was a problem hiding this comment.
Right — the errorless zero-event end wasn't covered. Fixed in 7bc168d: the backoff wait now applies whenever a subscription ends without having delivered an event (streamErr != nil || !sawEvent), so accepted-then-dead subscriptions escalate toward the ceiling as documented, which also damps the shard-steal ping-pong. Test added asserting escalation under repeated instant errorless closes.
| // AWS terminates the subscription (roughly every five minutes). Backpressure | ||
| // from an unread channel simply pauses event consumption; flow control of | ||
| // in-flight messages remains governed by checkpoint_limit. | ||
| type efoRecordSource struct { |
There was a problem hiding this comment.
No benchmarks for the new enhanced fan-out consumption path (CONTRIBUTING.md §1.3.4/§1.3.5)
aws_kinesis (input) is classified certified in internal/plugins/info.csv, and §1.3.4 requires "two distinct phases, both of which are required" — local benchmarking and real-endpoint benchmarking — with results recorded under docs/benchmark-results/, plus §1.3.5 "benchmarks have been run at various throughput levels so that we can determine CPU and memory trendlines based on usage."
This PR introduces an entirely new consumption path (efoRecordSource, HTTP/2 push via SubscribeToShard) whose stated purpose is throughput — 2MB/s per shard vs. the shared 5 reads/sec/shard budget — plus a poll_period throttle, and no benchmark run or result file accompanies it. There is no kinesis entry under docs/benchmark-results/, so the throughput/CPU/memory claims in the docs and CHANGELOG are unverified.
Please follow the process in docs/benchmarking.md and record the EFO vs. polling comparison under docs/benchmark-results/.
There was a problem hiding this comment.
Fair point on §1.3.4–§1.3.6 — the local and real-endpoint benchmark phases for the EFO path (vs the polling path, poll_period overhead, and the KCL EFO consumer comparison) will be recorded under docs/benchmark-results/ as a follow-up before this ships in a release. The real-endpoint phase needs actual Kinesis: LocalStack's kinesis-mock can't demonstrate the dedicated-throughput claims, the genuine 5-minute subscription lifetime, or the 5s subscription-takeover window, so that run will double as the real-AWS validation of the resubscribe/steal behaviour.
| if capped { | ||
| return nil, false, nil | ||
| } |
There was a problem hiding this comment.
A capped gate wait returns nil, false, nil — indistinguishable from "the shard had no records". The consumer loop treats that as an empty poll and, because Blocking() is false for this source, arms its failure backoff:
connect/internal/impl/aws/kinesis/input.go
Lines 591 to 596 in 7bc168d
That backoff is the reader's shared boffPool entry (InitialInterval 300ms, MaxInterval 5s —
connect/internal/impl/aws/kinesis/input.go
Lines 385 to 393 in 7bc168d
Reset() when a fetch actually returns records. So whenever poll_period exceeds maxGateWait (which is 1s for the default commit_period: 5s, so any poll_period above 1s — including the poll_period: 10s case exercised in input_test.go), every gate-capped return escalates the backoff purely as a side effect of the user's configured period.
Concrete effect with poll_period: 10s, commit_period: 5s (so maxGateWait = 1s) on a shard that is producing: the first Fetch returns records and resets the backoff; subsequent fetches are capped at 1s and return empty, so the loop waits 300ms → 450ms → 675ms → … → 5s between attempts. The gate can only open on an attempt that lands after it elapses, so the effective spacing between GetRecords calls becomes roughly poll_period plus up to MaxInterval (~15s observed for a configured 10s), and records that arrive just after the gate opens sit undelivered for that extra interval. It also means an error-recovery backoff is escalating during entirely healthy operation.
Suggested fix: distinguish "gate not yet open" from "shard empty" — e.g. have Fetch signal the capped case separately (or return a sentinel the loop recognises) so the consumer loop re-enters Fetch promptly without arming boff, leaving the gate itself as the only pacing mechanism.
There was a problem hiding this comment.
Fixed in d45bdff: a gate-capped Fetch now returns a dedicated errPollGateWaiting sentinel which the consumer loop treats like a timeout — no failure backoff, no log — so the gate is the only pacing mechanism and GetRecords spacing tracks poll_period. A test asserts spacing stays within 1.5x the configured period under a tight retry loop.
| if k.pollPeriod > k.commitPeriod { | ||
| mgr.Logger().Warnf("%v (%v) exceeds %v (%v); this will delay checkpoint updates and shard-lease renewal", kiFieldPollPeriod, k.pollPeriod, kiFieldCommitPeriod, k.commitPeriod) |
There was a problem hiding this comment.
This warning tells operators something the implementation specifically prevents. shardFetchWaitBound caps a single Fetch wait at min(1s, commit_period/2, batch_period/2) and the poll gate returns early once that cap is hit:
connect/internal/impl/aws/kinesis/input_record_source.go
Lines 156 to 171 in 7bc168d
so control always returns to the consumer loop's select — which includes <-commitCtx.Done() — within half a commit period, regardless of how large poll_period is. Checkpoint updates and lease renewal are therefore not delayed by poll_period > commit_period; whatever bounded delay exists is commit_period/2 and is independent of poll_period. The same claim is repeated in the field description ("Values above commit_period delay checkpointing and shard-lease renewal") at
connect/internal/impl/aws/kinesis/input.go
Lines 199 to 204 in 7bc168d
This trips both CONTRIBUTING §1.2.2 ("Unexpected behavior should emit warning or error logs. Normal operation should emit no logs") — a valid, working config emits a warning on every startup — and §1.1.1/§1.1.2 for the documentation accuracy.
Suggested fix: either drop the warning and correct the field description, or reword both to describe the actual consequence (poll_period above commit_period means a shard may go a full poll period without new records, so the committed sequence advances no faster than that).
There was a problem hiding this comment.
Correct — that warning predated the fetch-wait cap and became false once shardFetchWaitBound landed. Fixed in d45bdff: the warning is removed and the field description now states the actual consequence (a shard is polled at most once per period, so the committed sequence advances no faster than that; values above lease_period are rejected).
| // shard, so never retry faster than that. RandomizationFactor is zeroed | ||
| // so jitter never pulls a retry below that floor. | ||
| boff.InitialInterval = efoResubscribeFloor | ||
| boff.MaxInterval = time.Second * 30 |
There was a problem hiding this comment.
time.Second * 30 is a hardcoded, unnamed retry cap. The project Go patterns are explicit on both counts:
Configurable Time Parameters — Every time-related value (timeouts, backoffs, intervals, retry delays) must be exposed as a YAML-configurable field. Do not hardcode durations.
Magic Numbers — Name all numeric constants. Every literal number in logic must have a clear meaning through a named constant or variable.
This is the ceiling on how long a shard can sit unsubscribed after a failure, so an operator has no way to tighten or loosen recovery latency. Same applies to efoConsumerPollInterval = time.Second (
connect/internal/impl/aws/kinesis/input_efo.go
Lines 34 to 37 in 7bc168d
time.Second default in shardFetchWaitBound (connect/internal/impl/aws/kinesis/input.go
Lines 701 to 704 in 7bc168d
minBound is named.
efoResubscribeFloor is fine as-is — it encodes an AWS API limit rather than a tuning choice, and the comment says so.
Suggested fix: name these as package constants at minimum, and expose the resubscribe backoff ceiling as an advanced field under enhanced_fan_out (alongside the consumer_activation_timeout this PR already added).
There was a problem hiding this comment.
Done in d45bdff: max_resubscribe_interval is exposed as an advanced field under enhanced_fan_out (default 30s, validated >= the 1s API floor so it can't undercut it), the bare time.Second in shardFetchWaitBound is now the named constant maxShardFetchWait, and efoConsumerPollInterval carries a comment explaining why it stays internal (it's the describe cadence inside the configurable consumer_activation_timeout).
| ) | ||
| } | ||
|
|
||
| func TestIntegrationKinesisEFO(t *testing.T) { |
There was a problem hiding this comment.
aws_kinesis (input) is marked certified in internal/plugins/info.csv:
connect/internal/plugins/info.csv
Lines 19 to 21 in 7bc168d
The entire justification for this feature is throughput — the description and CHANGELOG both sell it as "2MB/s per shard … avoiding the shared 5 reads per second per shard limit" — yet the PR carries no benchmark evidence that the EFO path actually beats the polling path here. CONTRIBUTING §1.3.4 requires both phases (local benchmarking and real-endpoint benchmarking), §1.3.5 requires runs at various throughput levels for CPU/memory trendlines, and §1.3.6 requires a comparison against the equivalent Kafka Connect connector (the KCL EFO consumer) where one exists. Results are to be recorded under docs/benchmark-results/ — there is currently no Kinesis entry there.
Note rather than a blocker on the code itself, but for a perf-motivated change on a certified connector it is the evidence a reviewer needs: a real-endpoint run is also the only way to shake out the resubscribe/backoff behaviour under a genuine 5-minute AWS subscription lifetime, which LocalStack won't reproduce.
There was a problem hiding this comment.
Fair point on §1.3.4–§1.3.6 — the local and real-endpoint benchmark phases for the EFO path (vs the polling path, poll_period overhead, and the KCL EFO consumer comparison) will be recorded under docs/benchmark-results/ as a follow-up before this ships in a release. The real-endpoint phase needs actual Kinesis: LocalStack's kinesis-mock can't demonstrate the dedicated-throughput claims, the genuine 5-minute subscription lifetime, or the 5s subscription-takeover window, so that run will double as the real-AWS validation of the resubscribe/steal behaviour.
…ax_resubscribe_interval The poll_period startup warning claimed exceeding commit_period delays checkpointing, but the per-Fetch wait was already capped well below that, so the warning was both false and fired on valid configs. A capped poll_period gate wait also returned an error indistinguishable from an empty shard, arming the consumer's failure backoff during healthy operation and overshooting the configured pacing. Finally, the EFO resubscribe backoff ceiling was a bare 30s literal with no way to tune it.
| // AWS terminates the subscription (roughly every five minutes). Backpressure | ||
| // from an unread channel simply pauses event consumption; flow control of | ||
| // in-flight messages remains governed by checkpoint_limit. | ||
| type efoRecordSource struct { |
There was a problem hiding this comment.
Missing benchmarks for a throughput feature (CONTRIBUTING.md §1.3.4–§1.3.6)
aws_kinesis is a certified input (internal/plugins/info.csv:20), and this PR's entire purpose is read throughput — the CHANGELOG entry claims "2MB/s per shard of read throughput, avoiding the shared 5 reads per second per shard limit". CONTRIBUTING.md §1.3.4 requires two phases, both of which are required (local/Docker benchmarks plus real-endpoint benchmarks), §1.3.5 requires runs "at various throughput levels so that we can determine CPU and memory trendlines", and §1.3.6 requires a comparison against the equivalent Kafka Connect connector (which also supports enhanced fan-out).
There is no internal/impl/aws/kinesis/bench/ directory in this PR (the layout mandated by docs/benchmarking.md, and followed by e.g. internal/impl/aws/dynamodb/bench/ and internal/impl/aws/s3/bench/), and no docs/benchmark-results/aws-kinesis.md alongside the existing per-connector result files.
Suggested fix: add a bench/ suite for the Kinesis input covering both the polling path (with and without poll_period) and the enhanced fan-out path, run it at several throughput levels locally and against a real Kinesis stream, and record the results under docs/benchmark-results/ per §1.3.4. Without them there is no evidence that the new push path actually delivers the throughput the docs and CHANGELOG advertise, nor what its CPU/memory cost is versus polling.
There was a problem hiding this comment.
Fair point on §1.3.4–§1.3.6 — the local and real-endpoint benchmark phases for the EFO path (vs the polling path, poll_period overhead, and the KCL EFO consumer comparison) will be recorded under docs/benchmark-results/ as a follow-up before this ships in a release. The real-endpoint phase needs actual Kinesis: LocalStack's kinesis-mock can't demonstrate the dedicated-throughput claims, the genuine 5-minute subscription lifetime, or the 5s subscription-takeover window, so that run will double as the real-AWS validation of the resubscribe/steal behaviour.
Ticket: CON-541
Kinesis caps
GetRecordsat 5 calls/sec/shard, shared across every consumer of a stream, and theaws_kinesisinput polls as fast as the pipeline drains with no way to slow it down — so a single Connect instance can starve co-tenant consumers, and adding more consumers causesReadProvisionedThroughputExceededthrottling. This PR adds both remedies:poll_period(default0s): a minimum interval betweenGetRecordscalls per shard, for staying under the shared read budget. Validated againstlease_period(rejected above it) and warned againstcommit_period; the gate is serviced in bounded slices so checkpointing, lease renewal, and batch delivery are never starved.enhanced_fan_out(default off): consumes streams via a dedicated EFO consumer (SubscribeToShardHTTP/2 push, 2 MB/s/shard dedicated throughput). The named consumer is auto-registered per stream (in parallel) and never deregistered. Subscriptions resubscribe at the continuation sequence with a 1s floor between attempts; shard-handoff contention (ResourceInUseException) is absorbed by background retries; sequences rejected by Kinesis fall back to the oldest retained record; latest-mode starts are anchored to a fixed timestamp so retries cannot skip records.The consumer loop's record-fetch step is extracted behind a
shardRecordSourceinterface. Polling behavior is preserved exactly, with one deliberate exception (also a pre-existing bug onmain): a stored sequence that has aged out of the retention window now falls back to the oldest retained record instead of stalling the shard forever. Checkpointing, shard balancing, and ack semantics are unchanged; both features default off.Testing: full unit suite green under
-race; LocalStack integration — the pre-existingaws_kinesissuite passes unchanged (regression gate), plus new EFO (balanced + explicit-shard checkpoint capture) andpoll_periodsuites.task fmt/task lint/task docsclean. Note: EFO is verified against LocalStack's kinesis-mock; a real-AWS smoke test covering a two-instance shard steal is recommended before GA, since kinesis-mock doesn't enforce the 5s subscription-takeover window.