Skip to content

perf(ingest): parallelize SQS receive and delete in the S3 events source - #19396

Open
Davis-Zhang-Onehouse wants to merge 1 commit into
apache:masterfrom
Davis-Zhang-Onehouse:parallelize-sqs-receive-delete
Open

perf(ingest): parallelize SQS receive and delete in the S3 events source#19396
Davis-Zhang-Onehouse wants to merge 1 commit into
apache:masterfrom
Davis-Zhang-Onehouse:parallelize-sqs-receive-delete

Conversation

@Davis-Zhang-Onehouse

@Davis-Zhang-Onehouse Davis-Zhang-Onehouse commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

S3EventsSource drains its SQS queue serially, 10 messages at a time, for both receive and delete. ReceiveMessage and DeleteMessageBatch are capped by SQS at 10 entries per call, so draining a backlog is dominated by round-trip latency and concurrency is the only available lever. On a deep backlog these two serial phases dominate the ingestion cycle.

Two correctness problems in the existing loop compound this:

  1. The receive loop ends the entire batch on the first empty response. AWS documents this as unreliable — "in rare cases, you might receive empty responses even when a queue still contains messages, especially if you specified a low value for the WaitTimeSeconds parameter."
  2. DeleteMessageBatch partial failures are counted but never retried, and undeleted messages stay in flight, consume the in-flight quota, and are redelivered once the visibility timeout expires.

Summary and Changelog

Users draining a large SQS backlog through S3EventsSource get a substantially shorter fetch/commit cycle, since the receive and delete round-trips now overlap instead of running one at a time. Behaviour is unchanged at parallelism=1.

Changes:

  • Both phases fan out across a shared bounded thread pool. Calls are dispatched through an ExecutorCompletionService: the coordinating thread consumes each completion as it arrives and immediately refills the freed slot. invokeAll over a batch of calls in a loop was rejected as it is a barrier — a wave costs max(latency) rather than avg(latency), and no worker starts its next call until the slowest sibling returns. All counters and termination state stay on the coordinating thread; nothing is shared with the workers beyond a timing accumulator.
  • Admission control credits each in-flight call with a full maxMessagesPerRequest, so a call is never dispatched that messages already in flight would make redundant. Overshoot of maxMessagePerBatch is bounded to maxMessagesPerRequest - 1.
  • Drain detection scaled to poll cost. Under long polling, "an empty response is sent only if the polling wait time expires", so an empty costs a full WaitTimeSeconds while a non-empty one returns as soon as a message is available. The batch now requires however many empty responses fit in one ~20s window — 1 at WaitTimeSeconds=20, 2 at 10, 4 at 5, 20 at 1 — which bounds drain confirmation to roughly a single poll window at any setting, with no timer. Short polling samples only a subset of servers, so its empty responses are weak evidence but nearly free, and keep a higher fixed count (5). Empty responses are counted as a batch total rather than consecutively, since resetting on a stray message lets a queue with slow ingress hold the phase for its entire call budget.
  • Failure classification by SQS error code, with the HTTP status used only as a fallback for unrecognised codes (unknown 5xx transient, unknown 4xx not). SQS status codes do not track severity: RequestThrottled and OverLimit are both HTTP 400, ThrottlingException is 403, MalformedQueryString is 404. AwsErrorCode.THROTTLING_ERROR_CODES omits KmsThrottled, which is covered explicitly.
  • OverLimit treated as backpressure, not an error: it stops dispatch and returns what was fetched, since the received messages become deletable once the batch commits, which is what frees the in-flight quota. Non-retryable failures stop at the first occurrence rather than consuming the retry budget.
  • In-flight calls are drained, never cancelled. A ReceiveMessage that succeeded server-side has already made those messages invisible, so discarding the response would strand them for the full visibility timeout. Correspondingly, an empty result only surfaces as an error when nothing at all proved the queue reachable — a successful response, or an OverLimit returned by SQS itself, is evidence the endpoint, credentials and queue are healthy.
  • Delete partial failures aggregated and retried with exponential backoff. senderFault entries are set aside rather than retried, as no retry can fix them. A synthetic per-entry id (the batch-local index) replaces the message id, so duplicate message ids within a batch under at-least-once delivery cannot collide and cause SQS to reject the whole batch; the id doubles as the reverse lookup, so no map is needed.
  • Observability: a receive plan line, a one-time queue-config line, a drain break probe that reports live queue counters when a batch ends short, and a perf line reporting summed call time against wall time so it is visible whether the calls actually overlapped.

Deletes remain in Source.onCommit. Deleting inside the receive loop would lift the in-flight ceiling but would break the coupling between message deletion and the commit that consumed those messages.

No code was copied from another project.

Impact

  • Performance: receive and delete round-trips overlap, so cycle time on a deep backlog is bound by round-trip latency divided by parallelism rather than multiplied by call count.
  • New config: hoodie.streamer.s3.source.queue.processing.parallelism, default 16. Setting it to 1 restores the previous fully sequential behaviour.
  • Behaviour change: a batch no longer ends on a single empty response. This can make a batch fetch more messages than before against a queue that returns a spurious empty, which is the intended fix.
  • No public API changes, no storage-format changes, no breaking changes.
  • The shared SqsClient is thread-safe and remains a single instance. Its HTTP connection pool must be at least the worker count or workers block on connection acquisition; the SDK's default sync pool (maxConnections=50) already covers any value up to 50, so only a larger value causes the source to resize the pool — which is why software.amazon.awssdk:apache-client is declared provided rather than made a hard runtime requirement.

Risk Level

Medium. The change makes a previously single-threaded ingestion path concurrent and alters when a receive batch terminates.

Mitigations and verification:

  • TestCloudObjectsSelector 50/50, plus TestS3EventsMetaSelector and TestS3EventsSource; full mvn clean install clean.
  • Concurrency is covered with real threads, not mocks of concurrency: 250 messages drained across 8 workers asserting every message is fetched exactly once — no loss, no duplication.
  • Two tests are regression detectors rather than plain assertions: one holds a single call until its siblings have completed several further calls between them, so a return to wave-based dispatch fails the test rather than passing quietly; another pins the deliberate short-poll trade-off so a change to empty-response counting cannot alter it silently.
  • Each failure class is covered end to end — transient tolerated, non-retryable failing fast, OverLimit returning cleanly with and without fetched messages — plus the classifier itself as a unit table, and Error propagation.
  • Four behavioural guards were verified by mutation rather than line coverage: reverting each one causes its test to fail rather than pass silently.
  • parallelism=1 is retained as an escape hatch to the previous sequential behaviour.

Documentation Update

The new config hoodie.streamer.s3.source.queue.processing.parallelism carries a full withDocumentation description covering the SQS 10-per-call cap, the connection-pool requirement, and the parallelism=1 escape hatch.

hoodie.streamer.s3.source.queue.long.poll.wait has its description extended, since it now also governs how many empty responses confirm a drain.

No website changes are needed — both are existing/advanced source configs surfaced through the generated config docs.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

S3EventsSource drains its SQS queue serially, 10 messages at a time, for
both receive and delete. ReceiveMessage and DeleteMessageBatch are capped
by SQS at 10 entries per call, so draining a backlog is dominated by
round-trip latency and concurrency is the only available lever. Both
phases now fan out across a shared bounded thread pool.

Calls are dispatched through an ExecutorCompletionService rather than in
waves: the coordinating thread consumes each completion as it arrives and
immediately refills the freed slot. invokeAll over a batch of calls in a
loop is a barrier, so a wave costs max(latency) rather than avg(latency),
and no worker starts its next call until the slowest sibling returns. All
counters and termination state stay on the coordinating thread.

The previous loop ended the whole batch on the first empty response, which
AWS documents as unreliable ("in rare cases, you might receive empty
responses even when a queue still contains messages"). It is also the
expensive case: under long polling "an empty response is sent only if the
polling wait time expires", so an empty costs a full WaitTimeSeconds while
a non-empty one returns as soon as a message is available. Requiring
however many empty responses fit in one ~20s window bounds drain
confirmation to roughly a single poll window at any setting, with no timer
- 1 empty at WaitTimeSeconds=20, 2 at 10, 4 at 5, 20 at 1. Short polling
samples only a subset of servers, so its empties are weak evidence but
nearly free, and keep a higher fixed count.

Failures are classified by SQS error code, with HTTP status only as the
fallback for unrecognised codes, because SQS status codes do not track
severity: RequestThrottled and OverLimit are both 400, ThrottlingException
is 403, MalformedQueryString is 404. AwsErrorCode.THROTTLING_ERROR_CODES
omits KmsThrottled, so that is covered explicitly. OverLimit is the
in-flight ceiling and is treated as backpressure rather than an error,
since the received messages become deletable once the batch commits, which
is what frees the quota. Non-retryable failures stop at the first
occurrence instead of consuming the retry budget.

Calls in flight when dispatch stops are drained and their messages kept,
never cancelled: a ReceiveMessage that succeeded server-side has already
made those messages invisible, so discarding the response would strand
them for the full visibility timeout. An empty result surfaces as an error
only when nothing at all proved the queue reachable.

DeleteMessageBatch partial failures are aggregated across batches and
retried with exponential backoff; senderFault entries are set aside, as no
retry can fix them. A synthetic per-entry id (the batch-local index)
replaces the message id so duplicate ids within a batch under at-least-once
delivery cannot collide and cause SQS to reject the whole batch. Deletes
remain in Source.onCommit.

Adds hoodie.streamer.s3.source.queue.processing.parallelism (default 16;
set to 1 to restore fully sequential behaviour). The SDK's default sync
connection pool of 50 already covers any value up to 50, so only a larger
value causes the source to resize the pool.
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Jul 29, 2026
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.10319% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.55%. Comparing base (8e18999) to head (aa53168).

Files with missing lines Patch % Lines
...tilities/sources/helpers/CloudObjectsSelector.java 94.00% 14 Missing and 10 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19396      +/-   ##
============================================
+ Coverage     72.49%   72.55%   +0.05%     
- Complexity    32888    32979      +91     
============================================
  Files          2574     2574              
  Lines        149010   149383     +373     
  Branches      18749    18807      +58     
============================================
+ Hits         108032   108386     +354     
- Misses        32513    32528      +15     
- Partials       8465     8469       +4     
Components Coverage Δ
hudi-common 82.25% <ø> (+<0.01%) ⬆️
hudi-client 81.79% <ø> (+<0.01%) ⬆️
hudi-flink 78.63% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 58.67% <ø> (-0.01%) ⬇️
hudi-utilities 72.00% <94.10%> (+0.78%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (ø)
hudi-sync 70.62% <ø> (ø)
hudi-io 79.57% <ø> (ø)
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (-0.77%) ⬇️
Flag Coverage Δ
common-and-other-modules 47.56% <94.10%> (+0.12%) ⬆️
flink-integration-tests 47.63% <ø> (-0.01%) ⬇️
hadoop-mr-java-client 43.38% <ø> (ø)
integration-tests 13.50% <0.00%> (-0.04%) ⬇️
spark-client-hadoop-common 48.71% <ø> (+<0.01%) ⬆️
spark-java-tests 49.23% <0.00%> (-0.17%) ⬇️
spark-scala-tests 44.51% <0.00%> (-0.14%) ⬇️
utilities 36.21% <0.00%> (-0.13%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...g/apache/hudi/utilities/config/S3SourceConfig.java 97.91% <100.00%> (+0.29%) ⬆️
...tilities/sources/helpers/CloudObjectsSelector.java 92.16% <94.00%> (+12.16%) ⬆️

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants