Skip to content

#2212 retry 429 items and flush bulk requests by size - #2241

Open
dpol1 wants to merge 5 commits into
mainfrom
catch-up-with-legacy
Open

dpol1 wants to merge 5 commits into
mainfrom
catch-up-with-legacy

Conversation

@dpol1

@dpol1 dpol1 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@rzo1 asked for 429 retries and flushing by size before we move opensearch-java into external/opensearch (#2212). This PR adds both to AsyncBulkProcessor. The move will follow in a second PR on top of this one.

Today an item rejected with 429 goes straight to the listener, so StatusUpdaterBolt, IndexerBolt and DeletionBolt fail its tuple, and bulks are flushed by count or time only. With this change:

  • Items rejected with 429 are resent with the delays of the legacy BulkProcessor default backoff, 8 retries from 50 ms up to 2.7 s. Each retry carries only those items.
  • The bulk keeps its permit during the backoff, and afterBulk is called once, with the items of all attempts in request order.
  • A bulk is sent once its operations reach 5 MiB, the legacy default. The size is the NDJSON the transport writes, before compression.

There is one difference with the legacy module. It retries only when every failed item of the response is a 429, so a single 409, e.g. a create for a DISCOVERED URL that already exists, stops the retry of the whole bulk. Here the 429 items are resent whatever happened to the others.

I didn't use BulkIngester. It releases its in-flight slot before the listener has run and holds none while items wait for a retry, the opposite of what #2225 did for AsyncBulkProcessor. Its listener is also called once per attempt. In a local test with two writes for the same URL in one bulk (#832), a 200 then a 429, WaitAckCache acked the tuples before the second write was resent.

A few things worth a look:

  • Retries run on their own thread, because the flush thread can be blocked waiting for the permit of a retrying bulk.
  • Counting the size serializes each operation a second time, on the bolt's thread. A serialization error is now thrown by add(), where on main it failed the whole bulk.
  • The final flush in awaitClose() takes a permit with no timeout, as in the legacy module, so while bulks are retrying it can wait about 5 s of delays plus the round trips. Retries still pending when awaitClose() times out are dropped, also as in the legacy module. The bolts that track acks have already failed those tuples in cleanup().

The new tests don't need Docker and take about 5 s, and the module suite passes locally with Docker. I haven't yet run a crawl against an OpenSearch loaded enough to return 429s, nor StatusUpdaterBenchmark against main.

@dpol1
dpol1 added this pull request to stack #2243 October 16, 2026 15:39
@dpol1 dpol1 added this to the 4.0.0 milestone Oct 16, 2026
@dpol1 dpol1 self-assigned this Oct 16, 2026
@dpol1
dpol1 requested review from jnioche and rzo1 October 17, 2026 09:29

@rzo1 rzo1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, looks good to me.

A few small things:

  • In StatusUpdaterBolt, waitAck.addTuple() runs before connection.addToProcessor(op). Now that add() can throw on a serialization error, the tuple gets failed but its entry stays in the cache. A later bulk for the same URL would then ack a tuple that's already been failed, or the cache expiry would fail it a second time. IndexerBolt already calls waitAck.invalidate(docID) in its catch block. Could you do the same here?
  • The README says items rejected with 429 are resent. If OpenSearch rejects the whole request with a 429, it isn't retried, same as in the legacy module. Maybe say "items rejected with 429 in the bulk response", so nobody expects more than that.
  • If a retry hits a connection error, items that already got a 200 are failed as well. The legacy module does the same, and your test covers it, but one line in the README would help.
  • In duplicateIdIsAckedWhenOneWriteSucceeds, both tuples get acked even though the second write never went through. That's how it already worked since #832, so it's fine, but a short comment in the test saying so would save the next reader some head scratching.

@dpol1

dpol1 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@rzo1 I was going to ping you after the weekend tests, but you beat me to it :) pushed the fixes, thanks! StatusUpdaterBolt now drops the cache entry when add() throws, like IndexerBolt does, plus a test for it. README now also covers whole-request rejections and retries that fail as a whole, and the duplicate test has its comment.

@jnioche

jnioche commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

thanks @dpol1, 2 observations:

Every operation is now serialized twice. The second time happens on the bolt’s thread, for size counting. That’s cheap for status and delete operations, but IndexerBolt sends full page text, so it may cost noticeable CPU. The legacy module estimated the size instead of serializing.

DeletionBolt doesn’t handle the new exception from add(). At DeletionBolt.java:112-114 it calls waitAck.addTuple and then addToProcessor with no try/catch. A serialization error now escapes execute(), which in Storm kills the worker, and leaves a stale entry in WaitAckCache. Serializing a delete operation is unlikely to fail, but for consistency it should do what IndexerBolt and now StatusUpdaterBolt do: invalidate the entry, then fail the tuple.

@dpol1

dpol1 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@jnioche thanks, DeletionBolt now invalidates the entry and fails the tuple, with a test.

On the double serialization: in a microbenchmark on my laptop the size count costs about 1.5 µs per KB of JSON, so 16 µs for a page with 10 KB of text and 160 µs for 100 KB. That's roughly 70% of what the transport spends serializing the same operation. I'd keep the exact count so the 5 MiB limit matches the bulk body. If you prefer an estimate, as the legacy module did, I can do it in a follow-up.

dpol1 added 5 commits October 20, 2026 16:46
AsyncBulkProcessor passed the items that OpenSearch rejected with 429
straight to the listener, so the bolts failed their tuples at once.
The legacy BulkProcessor resends them with its default exponential
backoff when every failed item of the response has status 429.

AsyncBulkProcessor now resends the 429 items after the same delays
(50, 60, 80, 150, 280, 580, 1250 and 2740 ms), also when other items
failed with another status, for example a 409 from the create of a
DISCOVERED URL that is already known. Each retry carries only the
items still rejected, and the bulk keeps its concurrency permit until
the listener has run. afterBulk is called once per bulk, with the
items of all attempts in request order. If a retry request fails as a
whole, the whole bulk fails, as on the first attempt.

Retries run on their own thread, because the flush thread can be
blocked waiting for the permit that a retrying bulk holds. awaitClose()
waits for pending retries within its timeout and then drops the ones
still waiting, as the legacy BulkProcessor does.

Signed-off-by: Davide Polato <dpol1@apache.org>
The legacy BulkProcessor sends a bulk once its estimated size reaches
5 MiB. AsyncBulkProcessor flushed by count and time only, so large
documents could produce very large requests. The README claimed
size-based flushing.

When an operation is added, AsyncBulkProcessor now computes the bytes
that the transport writes for it: each NDJSON line serialized with the
transport's mapper, before compression, plus a newline. This serializes
each operation one more time, on the caller's thread. The bulk is sent
once the buffered operations reach 5 MiB, including the operation that
crosses the threshold.

The README now lists what AsyncBulkProcessor does.

Signed-off-by: Davide Polato <dpol1@apache.org>
AsyncBulkProcessor.add() serializes the operation and can throw.
StatusUpdaterBolt put the tuple in WaitAckCache before calling it, so
when add() threw, AbstractStatusUpdaterBolt failed the tuple while its
entry stayed in the cache. A later bulk for the same document ID then
acked the failed tuple, or the cache expiry failed it a second time.

StatusUpdaterBolt now invalidates the entry when add() throws, as
IndexerBolt already does.

Signed-off-by: Davide Polato <dpol1@apache.org>
The README now says that only the items rejected with 429 in the bulk
response are resent, and that a retry that fails as a whole, for
example on a connection error, makes IndexerBolt, DeletionBolt and
StatusUpdaterBolt fail the tuples of the whole bulk. The duplicate ID
test says why both tuples are acked.

Signed-off-by: Davide Polato <dpol1@apache.org>
AsyncBulkProcessor.add() serializes the operation and can throw.
DeletionBolt called it without a try/catch, so the exception escaped
execute(), which kills the Storm worker, and the tuple's entry stayed
in WaitAckCache.

DeletionBolt now logs the error, invalidates the entry and fails the
tuple, as IndexerBolt and StatusUpdaterBolt do.

Signed-off-by: Davide Polato <dpol1@apache.org>
@jnioche
jnioche force-pushed the catch-up-with-legacy branch from 2ed5475 to 026ae44 Compare October 20, 2026 15:46

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants