hudi-agent commented on code in PR #19396:
URL: https://github.com/apache/hudi/pull/19396#discussion_r3686693236
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/config/S3SourceConfig.java:
##########
@@ -90,4 +96,18 @@ public class S3SourceConfig extends HoodieConfig {
.markAdvanced()
.withDocumentation("Visibility timeout for messages in queue. After we
consume the message, queue will move the consumed "
+ "messages to in-flight state, these messages can't be consumed
again by source for this timeout period.");
+
+ public static final ConfigProperty<Integer>
S3_SOURCE_QUEUE_PROCESSING_PARALLELISM = ConfigProperty
+ .key(S3_SOURCE_PREFIX + "queue.processing.parallelism")
+ .defaultValue(16)
Review Comment:
🤖 The default here is 16, so every existing `S3EventsSource` deployment
flips from serial to 16-way concurrent SQS calls on upgrade rather than opting
in. The design does mitigate this well — worker count is `min(parallelism,
plannedReceiveCalls)` so idle/small queues stay effectively serial, and the
SDK's default pool of 50 covers 16 — but was a non-1 default intended for all
existing users here, or would defaulting to 1 (preserve today's behaviour, let
operators opt into the speedup) be the safer rollout? @yihua does the ingestion
behaviour change on upgrade seem acceptable to you?
<sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag
quality.</i></sub>
##########
hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelector.java:
##########
@@ -197,48 +789,452 @@ protected List<List<MessageTracker>>
createListPartitions(List<MessageTracker> s
}
/**
- * Delete batch of messages from queue.
+ * Deletes one batch (at most {@value #SQS_BATCH_MAX_ENTRIES} entries, the
SQS API cap) of messages
+ * and returns the trackers SQS reported as failed, so the caller can retry
them. DeleteMessageBatch
+ * can return partial failures even on an HTTP 200, and undeleted messages
stay in-flight and are
+ * redelivered once the visibility timeout expires, so failures must be
surfaced rather than only
+ * counted.
+ *
+ * <p>A synthetic per-entry id (the batch-local index) is used instead of
the message id: the id
+ * only has to be unique within the batch, and under at-least-once delivery
the same message id can
+ * appear twice, which would otherwise collide and make SQS reject the whole
batch.
*/
- protected void deleteBatchOfMessages(SqsClient sqs, String queueUrl,
List<MessageTracker> messagesToBeDeleted) {
+ protected List<FailedDelete> deleteBatchOfMessages(SqsClient sqs, String
queueUrl, List<MessageTracker> messagesToBeDeleted) {
if (messagesToBeDeleted.isEmpty()) {
- return;
+ return Collections.emptyList();
}
- DeleteMessageBatchRequest.Builder builder =
DeleteMessageBatchRequest.builder().queueUrl(queueUrl);
- List<DeleteMessageBatchRequestEntry> deleteEntries = new ArrayList<>();
-
- for (MessageTracker message : messagesToBeDeleted) {
+ ValidationUtils.checkArgument(messagesToBeDeleted.size() <=
SQS_BATCH_MAX_ENTRIES,
+ "DeleteMessageBatch accepts at most " + SQS_BATCH_MAX_ENTRIES + "
entries per call, got "
+ + messagesToBeDeleted.size());
+ List<DeleteMessageBatchRequestEntry> deleteEntries = new
ArrayList<>(messagesToBeDeleted.size());
+ for (int i = 0; i < messagesToBeDeleted.size(); i++) {
deleteEntries.add(
DeleteMessageBatchRequestEntry.builder()
- .id(message.messageId)
- .receiptHandle(message.receiptHandle)
+ .id(String.valueOf(i))
+ .receiptHandle(messagesToBeDeleted.get(i).receiptHandle)
.build());
}
- builder.entries(deleteEntries);
- DeleteMessageBatchResponse deleteResponse =
sqs.deleteMessageBatch(builder.build());
- List<String> deleteFailures =
- deleteResponse.failed().stream()
- .map(BatchResultErrorEntry::id)
- .collect(Collectors.toList());
- if (!deleteFailures.isEmpty()) {
- log.warn(
- "Failed to delete {} messages out of {} from queue.",
deleteFailures.size(), deleteEntries.size());
- } else {
+ DeleteMessageBatchResponse deleteResponse = sqs.deleteMessageBatch(
+
DeleteMessageBatchRequest.builder().queueUrl(queueUrl).entries(deleteEntries).build());
+ if (deleteResponse.failed().isEmpty()) {
log.debug("Successfully deleted {} messages from queue.",
deleteEntries.size());
+ return Collections.emptyList();
+ }
+ // Keep the SQS-provided reason (code + senderFault) with each failed
tracker so the caller can log
+ // why deletion failed once at the end rather than per batch.
+ List<FailedDelete> failed = new
ArrayList<>(deleteResponse.failed().size());
+ for (BatchResultErrorEntry error : deleteResponse.failed()) {
+ MessageTracker tracker = trackerForEntryId(messagesToBeDeleted,
error.id());
+ if (tracker == null) {
+ // SQS echoed an entry id that was never sent (ids are batch-local
indices assigned just above, so
+ // this is a protocol violation rather than something that happens in
practice). The failure cannot
+ // be attributed to a message: there is no receipt handle to retry
with, and it cannot be added to
+ // the residual set either, since that set is counted against
processedMessages to derive the
+ // deleted total and a phantom entry would corrupt it. So it is
reported here and nowhere else -
+ // meaning the summary's deleted count is an upper bound whenever this
WARN fires. The affected
+ // message stays in-flight and is redelivered after the visibility
timeout regardless.
+ log.warn("SQS reported a delete failure for unknown entry id \"{}\" on
queue {} (code={}); it cannot be "
+ + "mapped back to a message, so that message's deletion status is
unknown and the deleted count "
+ + "below may over-count by one.",
+ error.id(), queueUrl, error.code());
+ continue;
+ }
+ failed.add(FailedDelete.reported(tracker, error));
+ }
+ // Per-batch detail at DEBUG only; residual failures are summarized once
at WARN in
+ // deleteProcessedMessages so a transient blip across many batches does
not flood the logs.
+ log.debug("Failed to delete {} messages out of {} from queue {}.",
failed.size(), deleteEntries.size(), queueUrl);
+ return failed;
+ }
+
+ /**
+ * Resolves an entry id assigned by {@link #deleteBatchOfMessages} back to
its message. The id is the
+ * batch-local index, so no lookup map is needed. Returns {@code null} when
SQS echoes an id that was
+ * never sent (non-numeric or out of range), which the caller reports rather
than ignores.
+ */
+ private static MessageTracker trackerForEntryId(List<MessageTracker> batch,
String entryId) {
+ try {
+ int index = Integer.parseInt(entryId);
+ return index >= 0 && index < batch.size() ? batch.get(index) : null;
+ } catch (NumberFormatException e) {
+ return null;
}
}
/**
* Delete Queue Messages after hudi commit. This method will be invoked by
source.onCommit.
+ * Deletes run concurrently across a fixed pool of up to {@link
#processingParallelism} threads;
+ * batches that fail - whether SQS reported the entry as failed on an HTTP
200 or the call threw
+ * outright - are collected across all batches and retried up to {@value
#DELETE_MAX_RETRIES} times
+ * with exponential backoff, since undeleted messages stay in-flight,
consume the in-flight quota, and
+ * are redelivered once the visibility timeout expires. Any residual
failures after the retries are
+ * logged with their reason (see {@link #logDeleteFailures}).
+ *
+ * <p>A failed delete is deliberately not fatal. This runs from {@code
Source.onCommit}, which
+ * StreamSync calls <em>after</em> the Hudi commit has already landed, and
HoodieStreamer turns any
+ * exception out of the sync round into a job shutdown - so aborting here on
one throttled call would
+ * take ingestion down over a condition SQS itself recovers from by
redelivering. The one exception is
+ * a systemic failure: if calls were throwing and not a single message could
be deleted, that is a
+ * queue/credential/network problem rather than stale individual entries,
and it is surfaced.
*/
public void deleteProcessedMessages(SqsClient sqs, String queueUrl,
List<MessageTracker> processedMessages) {
- if (!processedMessages.isEmpty()) {
- // create batch for deletion, SES DeleteMessageBatchRequest only accept
max 10 entries
- List<List<MessageTracker>> deleteBatches =
createListPartitions(processedMessages, 10);
- for (List<MessageTracker> deleteBatch : deleteBatches) {
- deleteBatchOfMessages(sqs, queueUrl, deleteBatch);
+ if (processedMessages.isEmpty()) {
+ return;
+ }
+ long startMs = System.currentTimeMillis();
+ // create batch for deletion, DeleteMessageBatchRequest only accepts max
SQS_BATCH_MAX_ENTRIES entries
+ List<List<MessageTracker>> deleteBatches =
createListPartitions(processedMessages, SQS_BATCH_MAX_ENTRIES);
+ int totalBatches = deleteBatches.size();
+ int workers = Math.max(1, Math.min(processingParallelism, totalBatches));
+ AtomicLong busyNanos = new AtomicLong();
+ ExecutorService pool = newFixedThreadPool(DELETE_THREAD_PREFIX, workers);
+ // Failures SQS attributes to this caller (senderFault=true, e.g.
ReceiptHandleIsInvalid once the
+ // visibility timeout has expired) cannot succeed on a retry, so they are
set aside by the pass that
+ // reported them instead of consuming the retry budget - and are still
reported at the end.
+ List<FailedDelete> permanentFailures = new ArrayList<>();
+ DeleteStats stats = new DeleteStats();
+ int retries = 0;
+ try {
+ DeletePass pass = deleteBatchesConcurrently(pool, sqs, queueUrl,
deleteBatches, workers, busyNanos);
+ stats.add(pass);
+ List<FailedDelete> retryable = splitOffPermanentFailures(pass,
permanentFailures);
+ while (!retryable.isEmpty() && retries < DELETE_MAX_RETRIES) {
+ retries++;
+ long backoffMs = sleepBeforeDeleteRetry(queueUrl, retryable.size(),
retries);
+ List<MessageTracker> retryTrackers =
+ retryable.stream().map(failedDelete ->
failedDelete.message).collect(Collectors.toList());
+ pass = deleteBatchesConcurrently(pool, sqs, queueUrl,
+ createListPartitions(retryTrackers, SQS_BATCH_MAX_ENTRIES),
workers, busyNanos);
+ stats.add(pass);
+ retryable = splitOffPermanentFailures(pass, permanentFailures);
+ log.debug("Delete retry {} for queue {} (backoff {} ms) left {}
messages still failing.",
+ retries, queueUrl, backoffMs, retryable.size());
+ }
+ List<FailedDelete> residual = new ArrayList<>(permanentFailures);
+ residual.addAll(retryable);
+ long wallMs = System.currentTimeMillis() - startMs;
+ int deleted = processedMessages.size() - residual.size();
+ log.info("Deleted {} of {} processed messages from queue {} across {}
delete batches and {} SQS calls "
+ + "({} calls failed, {} retry passes) in {} ms ({} messages
failed to delete).",
+ deleted, processedMessages.size(), queueUrl, totalBatches,
stats.calls, stats.failedCalls,
+ retries, wallMs, residual.size());
+ if (stats.failedCalls > 0) {
+ // The residual WARN below groups by reason but carries no stack
trace; log the first cause once
+ // here so the SDK-level reason (throttling, connection acquisition
timeout, ...) is recoverable.
+ log.warn("{} of {} SQS DeleteMessageBatch calls failed for queue {};
their batches were retried and "
+ + "{} messages remain undeleted. Undeleted messages are
redelivered after the visibility "
+ + "timeout, so ingestion may see them again rather than losing
them.",
+ stats.failedCalls, stats.calls, queueUrl, residual.size(),
stats.firstThrown);
+ }
+ if (stats.fatalThrown != null) {
+ // A non-retryable failure that struck only part of the phase (a
session token expiring mid-run,
+ // say) leaves deleted > 0, so the systemic throw below stays silent
by design. Without this it
+ // would be reported only as an indistinguishable residual-failure
WARN, so name the condition
+ // explicitly: no retry can clear it, and it will recur on every
commit until an operator acts.
+ log.error("Non-retryable SQS DeleteMessageBatch failure for queue {};
stopped dispatching further "
+ + "delete batches ({} batches skipped) and did not retry,
since no retry can succeed. {} of {} "
+ + "messages remain undeleted and will be redelivered after the
visibility timeout. Check the "
+ + "queue url, its region, and the credentials/permissions of
this job.",
+ queueUrl, stats.skippedBatches, residual.size(),
processedMessages.size(), stats.fatalThrown);
+ }
+ if (!residual.isEmpty()) {
+ logDeleteFailures(queueUrl, residual, retries);
+ }
+ logParallelPerf("delete", queueUrl, workers, stats.calls, wallMs,
busyNanos.get());
+ // Nothing at all could be deleted and calls were throwing: systemic
(bad credentials, queue gone,
+ // network down) rather than individual stale entries, and the caller is
about to clear its tracked
+ // messages as if they had been handled, so surface it. A purely
senderFault residue is deliberately
+ // not fatal - every receipt handle being stale is a real queue state
that no retry or job failure
+ // can fix, and the messages are redelivered regardless.
+ //
+ // NOTE: this deliberately does not fire when every entry failed
server-side on an HTTP 200
+ // (senderFault=false, e.g. InternalError) with no call ever throwing.
That is an equally total
+ // failure to delete, but SQS reports genuinely broken plumbing - bad
credentials, a deleted queue -
+ // by throwing, so firstThrown is the sharper signal, and escalating a
persistent per-entry
+ // condition into a job shutdown would trade at-least-once redelivery
for an ingestion outage.
+ // Making that case visible belongs to the residual-failure metric
tracked as follow-up, not here.
+ // The extra answeredCalls() guard: a DeleteMessageBatch that returned
HTTP 200 proves the queue is
+ // reachable and the credentials work, even if it reported every entry
as failed. Without it, the
+ // deliberately-non-fatal all-senderFault case above (every receipt
handle stale after a long commit)
+ // turns into a job shutdown as soon as one unrelated call is also
throttled - and fanning out makes
+ // such a throttle more likely, not less.
+ if (deleted == 0 && stats.firstThrown != null && stats.answeredCalls()
== 0) {
+ throw new HoodieException("Failed to delete any of the " +
processedMessages.size()
+ + " processed messages from SQS queue " + queueUrl + " (" +
stats.failedCalls + " of "
+ + stats.calls + " DeleteMessageBatch calls threw, " +
residual.size()
+ + " messages still undeleted after " + retries + " retries)",
stats.firstThrown);
+ }
+ } finally {
+ shutdownThreadPool(pool);
+ }
+ }
+
+ /**
+ * Deletes the given batches concurrently on {@code pool} and returns the
outcome of the pass: every
+ * message that was not deleted, plus the call stats. Each batch's SQS call
time is accumulated into
+ * {@code busyNanos} so the caller can log the serialized-equivalent time
and prove the deletes
+ * actually overlapped.
+ *
+ * @param workers the pool's size, passed in rather than re-derived from
{@code deleteBatches}. A retry
+ * pass carries fewer batches than the first one, so deriving it here
would produce a second,
+ * smaller value under the same name as the caller's - and the caller's
is the one that sized the
+ * pool and is reported as {@code parallelism} on the perf line.
Over-dispatch is not a concern:
+ * {@code pending} is exhausted once every batch is submitted, whatever
the slot count says.
+ */
+ private DeletePass deleteBatchesConcurrently(ExecutorService pool, SqsClient
sqs, String queueUrl,
+ List<List<MessageTracker>>
deleteBatches, int workers,
+ AtomicLong busyNanos) {
+ CompletionService<BatchOutcome> completionService = new
ExecutorCompletionService<>(pool);
+ Iterator<List<MessageTracker>> pending = deleteBatches.iterator();
+ DeletePass pass = new DeletePass();
+ int outstanding = 0;
+ try {
+ // Dispatch incrementally rather than queueing every batch up front. The
pool stays saturated either
+ // way, but holding the untried batches back is what makes fail-fast
possible: on a non-retryable
+ // failure the remaining batches are simply never submitted. With a
deleted queue or revoked
+ // credentials that is the difference between ceil(M/10) doomed calls
and roughly one per worker.
Review Comment:
🤖 nit: could you use an explicit `case TRANSIENT:` instead of `default:`
here? With `default` a future `SqsFailureKind` value would silently be treated
as transient with no compile-time signal that a new case was missed.
<sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag
quality.</i></sub>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]