diff --git a/core/src/main/java/org/apache/stormcrawler/util/RetryAfterParser.java b/core/src/main/java/org/apache/stormcrawler/util/RetryAfterParser.java new file mode 100644 index 000000000..8c87e554f --- /dev/null +++ b/core/src/main/java/org/apache/stormcrawler/util/RetryAfterParser.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.stormcrawler.util; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses the value of a Retry-After HTTP + * response header, as sent by servers alongside a 429 (Too Many Requests) or 503 (Service + * Unavailable) response to request a back-off. + */ +public final class RetryAfterParser { + + private static final Logger LOG = LoggerFactory.getLogger(RetryAfterParser.class); + + /** + * Formatter for the HTTP-date form of the Retry-After header, e.g. {@code Wed, 21 Oct 2015 + * 07:28:00 GMT}. {@link DateTimeFormatter} is immutable and thread-safe, unlike {@code + * SimpleDateFormat}, which matters as this is shared across threads. + */ + private static final DateTimeFormatter HTTP_DATE_FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + /** Pre-compiled matcher for the numeric (delta-seconds) form of the Retry-After header. */ + private static final Pattern RETRY_AFTER_SECONDS = Pattern.compile("[0-9]+"); + + /** + * Upper bound on the length of a Retry-After header value we will attempt to parse. A + * delta-seconds value is at most 19 digits and an HTTP date ~29 chars; anything longer is + * rejected to bound the work done on this attacker-controlled value. + */ + private static final int MAX_RETRY_AFTER_VALUE_LENGTH = 64; + + private RetryAfterParser() {} + + /** + * Parses a Retry-After header value into a delay expressed in milliseconds, relative to now. + * The value is either a number of seconds (e.g. {@code 120}) or an HTTP date (e.g. {@code Wed, + * 21 Oct 2015 07:28:00 GMT}). + * + * @return the delay in milliseconds (possibly {@code 0}), or {@code -1} if the header is + * absent, malformed, in the past or implausibly long. + */ + public static long parseDelay(String retryAfter) { + if (StringUtils.isBlank(retryAfter)) { + return -1; + } + retryAfter = retryAfter.trim(); + // a valid Retry-After is either a delta-seconds value or an HTTP date; + // reject anything implausibly long to bound the work done on this + // attacker-controlled header value + if (retryAfter.length() > MAX_RETRY_AFTER_VALUE_LENGTH) { + return -1; + } + // delay expressed as a number of seconds + if (RETRY_AFTER_SECONDS.matcher(retryAfter).matches()) { + try { + return Math.multiplyExact(Long.parseLong(retryAfter), 1000L); + } catch (NumberFormatException | ArithmeticException e) { + // value too large to fit in a long, or its conversion to ms + // overflows - ignore + return -1; + } + } + // delay expressed as an HTTP date (IMF-fixdate form only, as produced by + // virtually all servers; the legacy RFC 850 / asctime forms are not accepted) + try { + Instant date = Instant.from(HTTP_DATE_FORMATTER.parse(retryAfter)); + long delay = date.toEpochMilli() - System.currentTimeMillis(); + return delay > 0 ? delay : -1; + } catch (DateTimeException e) { + LOG.debug("Invalid Retry-After header value: {}", retryAfter); + return -1; + } + } +} diff --git a/core/src/test/java/org/apache/stormcrawler/util/RetryAfterParserTest.java b/core/src/test/java/org/apache/stormcrawler/util/RetryAfterParserTest.java new file mode 100644 index 000000000..7d79389c1 --- /dev/null +++ b/core/src/test/java/org/apache/stormcrawler/util/RetryAfterParserTest.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.stormcrawler.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Random; +import org.junit.jupiter.api.Test; + +/** Unit and randomised (fuzz) coverage for {@link RetryAfterParser#parseDelay(String)}. */ +class RetryAfterParserTest { + + private static final DateTimeFormatter HTTP_DATE = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + @Test + void parseRetryAfterSeconds() { + assertEquals(120_000L, RetryAfterParser.parseDelay("120")); + // surrounding whitespace is tolerated + assertEquals(120_000L, RetryAfterParser.parseDelay(" 120 ")); + // zero is a valid (if pointless) delay and is not treated as "absent" + assertEquals(0L, RetryAfterParser.parseDelay("0")); + } + + @Test + void parseRetryAfterFutureDate() { + Instant future = Instant.now().plusSeconds(300); + long delay = RetryAfterParser.parseDelay(HTTP_DATE.format(future)); + // allow a small tolerance for the time elapsed during the call + assertTrue(delay > 250_000 && delay <= 300_000, "delay was " + delay); + } + + @Test + void parseRetryAfterPastDateReturnsMinusOne() { + Instant past = Instant.now().minusSeconds(300); + assertEquals(-1L, RetryAfterParser.parseDelay(HTTP_DATE.format(past))); + } + + @Test + void httpDateFormatterMatchesSpecAndJdkForTwoDigitDays() { + // the local formatter mirrors the parser's pattern, so a round-trip + // alone cannot catch a wrong pattern: cross-check it against the + // IMF-fixdate example of RFC 7231 and against the JDK's RFC 1123 + // formatter (identical output for two-digit days) + Instant fixed = Instant.parse("2015-10-21T07:28:00Z"); + assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", HTTP_DATE.format(fixed)); + assertEquals( + DateTimeFormatter.RFC_1123_DATE_TIME.format(fixed.atOffset(ZoneOffset.UTC)), + HTTP_DATE.format(fixed)); + } + + @Test + void parseRetryAfterRejectsUnpaddedSingleDigitDay() { + // RFC 1123 allows a single-digit day (as produced by the JDK's + // RFC_1123_DATE_TIME) but IMF-fixdate requires two digits; the parser + // is deliberately strict and rejects the unpadded form, even for a + // date in the future + OffsetDateTime future = OffsetDateTime.now(ZoneOffset.UTC).plusYears(1).withDayOfMonth(3); + String unpadded = DateTimeFormatter.RFC_1123_DATE_TIME.format(future); + assertTrue(unpadded.matches("\\w{3}, 3 \\w{3}.*"), "unexpected format: " + unpadded); + assertEquals(-1L, RetryAfterParser.parseDelay(unpadded)); + } + + @Test + void parseRetryAfterBlankOrMalformedReturnsMinusOne() { + assertEquals(-1L, RetryAfterParser.parseDelay(null)); + assertEquals(-1L, RetryAfterParser.parseDelay("")); + assertEquals(-1L, RetryAfterParser.parseDelay(" ")); + assertEquals(-1L, RetryAfterParser.parseDelay("not-a-date")); + assertEquals(-1L, RetryAfterParser.parseDelay("12.5")); + assertEquals(-1L, RetryAfterParser.parseDelay("-5")); + } + + @Test + void parseRetryAfterOverlongValueReturnsMinusOne() { + // an implausibly long header value is rejected without further parsing + String tooLong = "1".repeat(65); + assertEquals(-1L, RetryAfterParser.parseDelay(tooLong)); + } + + @Test + void parseRetryAfterParseOverflowReturnsMinusOne() { + // too large to fit in a long at all + assertEquals(-1L, RetryAfterParser.parseDelay("999999999999999999999999999")); + } + + @Test + void parseRetryAfterMillisecondOverflowReturnsMinusOne() { + // parses as a valid long, but * 1000 overflows a long (regression: was a + // silent overflow yielding a negative delay) + assertEquals(-1L, RetryAfterParser.parseDelay("3687950425865536959")); + assertEquals(-1L, RetryAfterParser.parseDelay(Long.toString(Long.MAX_VALUE))); + } + + @Test + void parseRetryAfterRandomSecondsRoundTrip() { + Random rnd = new Random(7); + for (int i = 0; i < 1_000; i++) { + long secs = rnd.nextInt(1_000_000); + assertEquals(secs * 1000L, RetryAfterParser.parseDelay(Long.toString(secs))); + } + } + + @Test + void parseRetryAfterNeverThrowsForRandomInput() { + Random rnd = new Random(42); + for (int i = 0; i < 10_000; i++) { + String input = randomRetryAfterValue(rnd); + long result = RetryAfterParser.parseDelay(input); + // contract: never throws, never returns anything below -1 + assertTrue(result >= -1L, "value " + result + " for input <" + input + ">"); + } + } + + /** + * Produces a wide spectrum of inputs: digit runs (including values that overflow a long or + * overflow when converted to ms), real HTTP dates (past and future), blanks, random printable + * garbage and nulls. + */ + private static String randomRetryAfterValue(Random rnd) { + switch (rnd.nextInt(5)) { + case 0: + int len = 1 + rnd.nextInt(40); + StringBuilder digits = new StringBuilder(); + for (int i = 0; i < len; i++) { + digits.append((char) ('0' + rnd.nextInt(10))); + } + return digits.toString(); + case 1: + long offset = (rnd.nextBoolean() ? 1L : -1L) * rnd.nextInt(1_000_000); + return HTTP_DATE.format(Instant.now().plusSeconds(offset)); + case 2: + return ""; + case 3: + int glen = rnd.nextInt(30); + StringBuilder garbage = new StringBuilder(); + for (int i = 0; i < glen; i++) { + garbage.append((char) (32 + rnd.nextInt(95))); + } + return garbage.toString(); + default: + return null; + } + } +} diff --git a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/Constants.java b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/Constants.java index d747aafe2..67f5cdf47 100644 --- a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/Constants.java +++ b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/Constants.java @@ -43,4 +43,12 @@ private Constants() {} public static final String URLFRONTIER_UPDATER_MAX_MESSAGES_KEY = "urlfrontier.updater.max.messages"; public static final String URLFRONTIER_CRAWL_ID_KEY = "urlfrontier.crawlid"; + + /** + * Maximum delay in seconds honoured when a server requests a back-off via the Retry-After HTTP + * response header. {@code -1} disables the cap. Defaults to 86400 (24h). + */ + public static final String URLFRONTIER_MAX_RETRY_AFTER_KEY = "urlfrontier.max.retry.after"; + + public static final int URLFRONTIER_MAX_RETRY_AFTER_DEFAULT = 86400; } diff --git a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/HostBlockBolt.java b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/HostBlockBolt.java new file mode 100644 index 000000000..2ec791f6e --- /dev/null +++ b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/HostBlockBolt.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.stormcrawler.urlfrontier; + +import crawlercommons.urlfrontier.CrawlID; +import crawlercommons.urlfrontier.URLFrontierGrpc; +import crawlercommons.urlfrontier.URLFrontierGrpc.URLFrontierStub; +import crawlercommons.urlfrontier.Urlfrontier.BlockQueueParams; +import crawlercommons.urlfrontier.Urlfrontier.Empty; +import io.grpc.ManagedChannel; +import io.grpc.stub.StreamObserver; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.apache.storm.task.OutputCollector; +import org.apache.storm.task.TopologyContext; +import org.apache.storm.topology.OutputFieldsDeclarer; +import org.apache.storm.topology.base.BaseRichBolt; +import org.apache.storm.tuple.Tuple; +import org.apache.stormcrawler.Metadata; +import org.apache.stormcrawler.protocol.ProtocolResponse; +import org.apache.stormcrawler.util.ConfUtils; +import org.apache.stormcrawler.util.MetadataTransfer; +import org.apache.stormcrawler.util.RetryAfterParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Consumes the {@code queue} stream emitted by the status updater and blocks a queue in URLFrontier + * via {@code blockQueueUntil} whenever the tuple's metadata reports a rate-limit response (HTTP 429 + * or 503) carrying a Retry-After + * header. See issues #867 and #784. + * + *

Wire it with a fields grouping on {@code "key"} from the status updater's {@code queue} + * stream. The {@code "key"} is the frontier queue key derived from {@code partition.url.mode} (the + * same setting the frontier uses to assign queues), so the block targets the matching queue. The + * honoured delay is capped by {@code urlfrontier.max.retry.after} (in seconds, default 86400, -1 to + * disable the cap). + * + *

The metadata on the queue stream is filtered by {@code metadata.persist}: with the + * default configuration neither {@code fetch.statusCode} nor the Retry-After header survive the + * filter and this bolt never blocks anything. Both keys must be added to {@code metadata.persist}: + * + *

+ *   metadata.persist:
+ *    - fetch.statusCode
+ *    - protocol.retry-after
+ * 
+ * + * (the second entry depends on {@code protocol.md.prefix}). A warning is logged at startup when the + * configuration would strip them. + * + *

Connects via {@code urlfrontier.address}, falling back to {@code urlfrontier.host} / {@code + * urlfrontier.port}. The block is issued with {@code local=false} and propagates to the whole + * cluster, so with several frontier nodes a single connection to any one of them is enough. + * + *

The block is fire-and-forget with a short deadline: a failed or expired call is logged but the + * tuple is acked anyway. A missed block means the host is fetched once more and the next 429 + * re-emits the signal. + */ +public class HostBlockBolt extends BaseRichBolt { + + private static final Logger LOG = LoggerFactory.getLogger(HostBlockBolt.class); + + /** Metadata key set by the FetcherBolt with the HTTP status code of the fetch. */ + private static final String STATUS_CODE_KEY = "fetch.statusCode"; + + /** Name of the Retry-After HTTP header, lower-cased as stored by the protocol layer. */ + private static final String RETRY_AFTER_HEADER = "retry-after"; + + /** + * Queue key used by the status updaters when no partition key can be derived from a URL. The + * queue is shared by unrelated URLs, so it is never blocked. + */ + private static final String DEFAULT_QUEUE_KEY = "_DEFAULT_"; + + /** + * Deadline for the fire-and-forget blockQueueUntil call, so a frontier outage cannot pile up + * pending RPCs and flush stale blocks on reconnect. + */ + private static final long BLOCK_RPC_DEADLINE_SECS = 10; + + private static final StreamObserver NOOP_OBSERVER = + new StreamObserver<>() { + @Override + public void onNext(Empty value) {} + + @Override + public void onError(Throwable t) { + LOG.warn("blockQueueUntil failed", t); + } + + @Override + public void onCompleted() {} + }; + + private OutputCollector collector; + private ManagedChannel channel; + private URLFrontierStub frontier; + private String globalCrawlID; + + /** Metadata key holding the Retry-After header, including the protocol prefix. */ + private String retryAfterKey; + + /** Upper bound in ms for the honoured Retry-After delay; -1 means no cap. */ + private long maxRetryAfterMs; + + @Override + public void prepare(Map conf, TopologyContext context, OutputCollector c) { + this.collector = c; + this.globalCrawlID = + ConfUtils.getString(conf, Constants.URLFRONTIER_CRAWL_ID_KEY, CrawlID.DEFAULT); + // the protocol layer stores response headers in the metadata with this + // prefix; the FetcherBolt merges them into the status metadata + this.retryAfterKey = + ConfUtils.getString(conf, ProtocolResponse.PROTOCOL_MD_PREFIX_PARAM, "") + + RETRY_AFTER_HEADER; + long maxRetryAfterSecs = + ConfUtils.getLong( + conf, + Constants.URLFRONTIER_MAX_RETRY_AFTER_KEY, + Constants.URLFRONTIER_MAX_RETRY_AFTER_DEFAULT); + this.maxRetryAfterMs = maxRetryAfterSecs < 0 ? -1L : maxRetryAfterSecs * 1000L; + // the status updater emits the queue stream after the metadata.persist + // filter: warn upfront when the keys this bolt relies on would be + // stripped, as the bolt would otherwise silently never block anything + Set missing = missingQueueStreamKeys(conf, retryAfterKey); + if (!missing.isEmpty()) { + LOG.warn( + "{} do(es) not survive the metadata.persist filter: the queue stream will" + + " never carry them and this bolt will never block a queue. Add the" + + " missing entries to metadata.persist.", + missing); + } + // a single connection is enough even with several frontier nodes, as + // the block is issued with local=false and propagates cluster-wide + List addresses = + ConfUtils.loadListFromConf(Constants.URLFRONTIER_ADDRESS_KEY, conf); + String address; + if (addresses.isEmpty()) { + address = + ConfUtils.getString( + conf, + Constants.URLFRONTIER_HOST_KEY, + Constants.URLFRONTIER_DEFAULT_HOST) + + ":" + + ConfUtils.getInt( + conf, + Constants.URLFRONTIER_PORT_KEY, + Constants.URLFRONTIER_DEFAULT_PORT); + } else { + Collections.sort(addresses); + address = addresses.get(context.getThisTaskIndex() % addresses.size()); + } + this.channel = ManagedChannelUtil.createChannel(address); + this.frontier = URLFrontierGrpc.newStub(channel).withWaitForReady(); + } + + @Override + public void execute(Tuple t) { + final String key = t.getStringByField("key"); + final Metadata metadata = (Metadata) t.getValueByField("metadata"); + final long blockUntil = + blockUntilFor( + key, metadata, retryAfterKey, maxRetryAfterMs, System.currentTimeMillis()); + if (blockUntil > 0) { + LOG.debug("Blocking queue {} until {}", key, blockUntil); + BlockQueueParams params = + BlockQueueParams.newBuilder() + .setKey(key) + .setCrawlID(globalCrawlID) + .setTime(blockUntil) + .setLocal(false) + .build(); + frontier.withDeadlineAfter(BLOCK_RPC_DEADLINE_SECS, TimeUnit.SECONDS) + .blockQueueUntil(params, NOOP_OBSERVER); + } + collector.ack(t); + } + + /** + * Decides whether a queue-stream tuple carries a server-requested back-off worth enforcing. + * Only a rate-limit (429) or unavailable (503) response with a valid Retry-After header + * qualifies; the requested delay is capped by {@code maxRetryAfterMs} unless negative. The + * shared {@code _DEFAULT_} queue is never blocked. + * + * @return the absolute time to block the queue until, in epoch seconds, or {@code -1} if the + * tuple does not call for a block + */ + static long blockUntilFor( + String key, Metadata metadata, String retryAfterKey, long maxRetryAfterMs, long nowMs) { + if (metadata == null || DEFAULT_QUEUE_KEY.equals(key)) { + return -1L; + } + final String statusCode = metadata.getFirstValue(STATUS_CODE_KEY); + // only on a rate-limit (429) or unavailable (503) response does + // Retry-After signal a host back-off worth acting on + if (!"429".equals(statusCode) && !"503".equals(statusCode)) { + return -1L; + } + long retryAfterMs = RetryAfterParser.parseDelay(metadata.getFirstValue(retryAfterKey)); + if (retryAfterMs <= 0) { + return -1L; + } + if (maxRetryAfterMs >= 0 && retryAfterMs > maxRetryAfterMs) { + retryAfterMs = maxRetryAfterMs; + } + long blockUntilMs; + try { + blockUntilMs = Math.addExact(nowMs, retryAfterMs); + } catch (ArithmeticException e) { + // uncapped delay pushed the block time past Long.MAX_VALUE: + // clamp instead of wrapping negative and skipping the block + blockUntilMs = Long.MAX_VALUE; + } + return blockUntilMs / 1000L; + } + + /** + * Returns the queue-stream keys this bolt relies on that would not survive the {@code + * metadata.persist} filter applied by the status updater before emitting. + */ + static Set missingQueueStreamKeys(Map conf, String retryAfterKey) { + Metadata probe = new Metadata(); + probe.setValue(STATUS_CODE_KEY, "429"); + probe.setValue(retryAfterKey, "1"); + Metadata filtered = MetadataTransfer.getInstance(conf).filter(probe); + Set missing = new LinkedHashSet<>(); + if (filtered.getFirstValue(STATUS_CODE_KEY) == null) { + missing.add(STATUS_CODE_KEY); + } + if (filtered.getFirstValue(retryAfterKey) == null) { + missing.add(retryAfterKey); + } + return missing; + } + + @Override + public void declareOutputFields(OutputFieldsDeclarer declarer) { + // terminal bolt + } + + @Override + public void cleanup() { + if (channel != null) { + channel.shutdown(); + } + } +} diff --git a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java index 34ba3a4c9..5e5b3ca49 100644 --- a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java +++ b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java @@ -59,6 +59,7 @@ import org.apache.storm.task.OutputCollector; import org.apache.storm.task.TopologyContext; import org.apache.storm.tuple.Tuple; +import org.apache.storm.tuple.Values; import org.apache.stormcrawler.Metadata; import org.apache.stormcrawler.metrics.CrawlerMetrics; import org.apache.stormcrawler.metrics.ScopedCounter; @@ -378,6 +379,12 @@ public void store( partitionKey = "_DEFAULT_"; } + // send a tuple on the queue stream in case a bolt + // wants to handle it + super.collector.emit( + org.apache.stormcrawler.Constants.QUEUE_STREAM_NAME, + new Values(partitionKey, metadata)); + final Map mdCopy = new HashMap<>(metadata.size()); for (String k : metadata.keySet()) { String[] vals = metadata.getValues(k); diff --git a/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltDecisionTest.java b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltDecisionTest.java new file mode 100644 index 000000000..c3d40c078 --- /dev/null +++ b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltDecisionTest.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.stormcrawler.urlfrontier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.stormcrawler.Metadata; +import org.junit.jupiter.api.Test; + +/** + * Unit coverage for {@link HostBlockBolt#blockUntilFor(String, Metadata, String, long, long)} - the + * decision whether a queue-stream tuple carries a server-requested back-off worth enforcing - and + * for {@link HostBlockBolt#missingQueueStreamKeys(Map, String)} - the startup check on {@code + * metadata.persist}. + */ +class HostBlockBoltDecisionTest { + + private static final String KEY = "example.com"; + private static final String RETRY_AFTER_KEY = "protocol.retry-after"; + private static final long NOW_MS = 1_700_000_000_000L; + private static final long NO_CAP = -1L; + + private static Metadata md(String statusCode, String retryAfter) { + Metadata md = new Metadata(); + if (statusCode != null) { + md.setValue("fetch.statusCode", statusCode); + } + if (retryAfter != null) { + md.setValue(RETRY_AFTER_KEY, retryAfter); + } + return md; + } + + @Test + void blocksOn429WithRetryAfterSeconds() { + long blockUntil = + HostBlockBolt.blockUntilFor(KEY, md("429", "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS); + assertEquals((NOW_MS + 120_000L) / 1000L, blockUntil); + } + + @Test + void blocksOn503WithRetryAfterSeconds() { + long blockUntil = + HostBlockBolt.blockUntilFor(KEY, md("503", "60"), RETRY_AFTER_KEY, NO_CAP, NOW_MS); + assertEquals((NOW_MS + 60_000L) / 1000L, blockUntil); + } + + @Test + void ignoresOtherStatusCodesEvenWithHeader() { + assertEquals( + -1L, + HostBlockBolt.blockUntilFor( + KEY, md("200", "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + assertEquals( + -1L, + HostBlockBolt.blockUntilFor( + KEY, md("301", "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + assertEquals( + -1L, + HostBlockBolt.blockUntilFor( + KEY, md("403", "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void ignores429WithoutHeader() { + assertEquals( + -1L, + HostBlockBolt.blockUntilFor(KEY, md("429", null), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void ignoresMissingStatusCode() { + assertEquals( + -1L, + HostBlockBolt.blockUntilFor(KEY, md(null, "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void ignoresMalformedHeaderAndZeroDelay() { + assertEquals( + -1L, + HostBlockBolt.blockUntilFor( + KEY, md("429", "not-a-date"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + // a zero delay is not worth a frontier round-trip + assertEquals( + -1L, + HostBlockBolt.blockUntilFor(KEY, md("429", "0"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void capsDelayAtConfiguredMaximum() { + long capMs = 3_600_000L; // one hour + long blockUntil = + HostBlockBolt.blockUntilFor( + KEY, md("429", "86400"), RETRY_AFTER_KEY, capMs, NOW_MS); + assertEquals((NOW_MS + capMs) / 1000L, blockUntil); + } + + @Test + void negativeCapMeansUncapped() { + long blockUntil = + HostBlockBolt.blockUntilFor( + KEY, md("429", "86400"), RETRY_AFTER_KEY, NO_CAP, NOW_MS); + assertEquals((NOW_MS + 86_400_000L) / 1000L, blockUntil); + } + + @Test + void hugeUncappedDelayClampsInsteadOfOverflowing() { + // 9223372036854775 seconds survive the parser's multiplyExact (just + // below Long.MAX_VALUE in ms); the addition of nowMs must clamp, not + // wrap negative and silently skip the block (regression) + long blockUntil = + HostBlockBolt.blockUntilFor( + KEY, md("429", "9223372036854775"), RETRY_AFTER_KEY, NO_CAP, NOW_MS); + assertEquals(Long.MAX_VALUE / 1000L, blockUntil); + assertTrue(blockUntil > NOW_MS / 1000L); + } + + @Test + void defaultQueueSentinelIsNeverBlocked() { + // a 429 on a URL whose partition key fell back to the shared catch-all + // queue must not stall every unrelated URL in it + assertEquals( + -1L, + HostBlockBolt.blockUntilFor( + "_DEFAULT_", md("429", "120"), RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void nullMetadataIsIgnored() { + assertEquals(-1L, HostBlockBolt.blockUntilFor(KEY, null, RETRY_AFTER_KEY, NO_CAP, NOW_MS)); + } + + @Test + void missingQueueStreamKeysReportsBothWithDefaultConfig() { + // with the default metadata.persist neither key survives the filter + Set missing = + HostBlockBolt.missingQueueStreamKeys(new HashMap<>(), RETRY_AFTER_KEY); + assertEquals(Set.of("fetch.statusCode", RETRY_AFTER_KEY), missing); + } + + @Test + void missingQueueStreamKeysEmptyWhenBothPersisted() { + Map conf = new HashMap<>(); + conf.put("metadata.persist", List.of("fetch.statusCode", RETRY_AFTER_KEY)); + assertTrue(HostBlockBolt.missingQueueStreamKeys(conf, RETRY_AFTER_KEY).isEmpty()); + } + + @Test + void missingQueueStreamKeysHonoursWildcards() { + Map conf = new HashMap<>(); + conf.put("metadata.persist", List.of("fetch.*", "protocol.*")); + assertTrue(HostBlockBolt.missingQueueStreamKeys(conf, RETRY_AFTER_KEY).isEmpty()); + } +} diff --git a/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltTest.java b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltTest.java new file mode 100644 index 000000000..8f8afb6fd --- /dev/null +++ b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/HostBlockBoltTest.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.stormcrawler.urlfrontier; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import crawlercommons.urlfrontier.CrawlID; +import crawlercommons.urlfrontier.URLFrontierGrpc; +import crawlercommons.urlfrontier.URLFrontierGrpc.URLFrontierBlockingStub; +import crawlercommons.urlfrontier.Urlfrontier.BlockQueueParams; +import crawlercommons.urlfrontier.Urlfrontier.GetParams; +import crawlercommons.urlfrontier.Urlfrontier.URLInfo; +import io.grpc.ManagedChannel; +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.apache.storm.task.OutputCollector; +import org.apache.storm.tuple.Tuple; +import org.apache.stormcrawler.Metadata; +import org.apache.stormcrawler.TestOutputCollector; +import org.apache.stormcrawler.TestUtil; +import org.apache.stormcrawler.persistence.Status; +import org.apache.stormcrawler.protocol.ProtocolResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class HostBlockBoltTest { + + private static final String HOST = "example.com"; + + private URLFrontierContainer container; + private ManagedChannel channel; + private URLFrontierBlockingStub blocking; + + @BeforeEach + void before() { + String image = "crawlercommons/url-frontier"; + String version = System.getProperty("urlfrontier-version"); + if (version != null) { + image += ":" + version; + } + container = new URLFrontierContainer(image); + container.start(); + var connection = container.getFrontierConnection(); + channel = ManagedChannelUtil.createChannel(connection.getHost(), connection.getPort()); + blocking = URLFrontierGrpc.newBlockingStub(channel); + } + + @AfterEach + void after() { + if (channel != null) { + channel.shutdownNow(); + } + container.close(); + } + + private Map frontierConfig() { + var connection = container.getFrontierConnection(); + Map config = new HashMap<>(); + config.put(Constants.URLFRONTIER_HOST_KEY, connection.getHost()); + config.put(Constants.URLFRONTIER_PORT_KEY, connection.getPort()); + return config; + } + + /** Seeds one DISCOVERED url for {@link #HOST} so the queue exists and is fetchable. */ + private void seedOneUrl() { + StatusUpdaterBolt seeder = new StatusUpdaterBolt(); + TestOutputCollector out = new TestOutputCollector(); + Map config = frontierConfig(); + config.put( + "urlbuffer.class", "org.apache.stormcrawler.persistence.urlbuffer.SimpleURLBuffer"); + config.put("scheduler.class", "org.apache.stormcrawler.persistence.DefaultScheduler"); + config.put("status.updater.cache.spec", "maximumSize=10000,expireAfterAccess=1h"); + config.put("urlfrontier.updater.max.messages", 1); + config.put("urlfrontier.cache.expireafter.sec", 10); + seeder.prepare(config, TestUtil.getMockedTopologyContext(), new OutputCollector(out)); + + String url = "http://" + HOST + "/"; + Tuple tuple = mock(Tuple.class); + when(tuple.getValueByField("status")).thenReturn(Status.DISCOVERED); + when(tuple.getStringByField("url")).thenReturn(url); + when(tuple.getValueByField("metadata")).thenReturn(new Metadata()); + seeder.execute(tuple); + + await().atMost(20, TimeUnit.SECONDS) + .until( + () -> + out.getAckedTuples().stream() + .anyMatch(t -> url.equals(t.getStringByField("url")))); + seeder.cleanup(); + } + + /** Returns the set of queue keys currently handed out by getURLs. */ + private Set keysFromGetURLs() { + GetParams params = + GetParams.newBuilder() + .setCrawlID(CrawlID.DEFAULT) + .setMaxQueues(10) + .setMaxUrlsPerQueue(10) + .build(); + Set keys = new HashSet<>(); + Iterator it = blocking.getURLs(params); + while (it.hasNext()) { + keys.add(it.next().getKey()); + } + return keys; + } + + @Test + @Timeout(value = 2, unit = TimeUnit.MINUTES) + void blocksHostQueueUntilTime() { + seedOneUrl(); + + long nowSecs = System.currentTimeMillis() / 1000L; + + // block the host for an hour via the bolt under test: a queue-stream + // tuple whose metadata reports a 429 with a Retry-After of one hour. + // The bolt is configured through urlfrontier.address to cover the + // address resolution as well + HostBlockBolt bolt = new HostBlockBolt(); + var connection = container.getFrontierConnection(); + Map conf = new HashMap<>(); + conf.put( + Constants.URLFRONTIER_ADDRESS_KEY, + connection.getHost() + ":" + connection.getPort()); + conf.put(ProtocolResponse.PROTOCOL_MD_PREFIX_PARAM, "protocol."); + bolt.prepare(conf, TestUtil.getMockedTopologyContext(), mock(OutputCollector.class)); + Tuple t = mock(Tuple.class); + when(t.getStringByField("key")).thenReturn(HOST); + Metadata md = new Metadata(); + md.setValue("fetch.statusCode", "429"); + md.setValue("protocol.retry-after", "3600"); + when(t.getValueByField("metadata")).thenReturn(md); + bolt.execute(t); + + // give the fire-and-forget RPC time to land before polling: getURLs + // must never hand the URL out, and URLFrontier exposes no read API + // for the block state. Polling getURLs() before the block is + // effective would put the URL in-flight and make the assertion below + // pass even without a block, so a blind delay is the only option + await().pollDelay(Duration.ofSeconds(2)).atMost(3, TimeUnit.SECONDS).until(() -> true); + assertTrue( + keysFromGetURLs().isEmpty(), + "the URL was handed out although the queue should be blocked"); + + // unblock (a past time releases the queue) and the host becomes + // available again; the URL never went in-flight, so this cannot be + // satisfied by the in-flight timeout + blocking.blockQueueUntil( + BlockQueueParams.newBuilder() + .setKey(HOST) + .setCrawlID(CrawlID.DEFAULT) + .setTime(nowSecs - 1) + .build()); + await().atMost(15, TimeUnit.SECONDS).until(() -> keysFromGetURLs().contains(HOST)); + + bolt.cleanup(); + } +} diff --git a/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBoltTest.java b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBoltTest.java index 32d827e32..d092a8e1f 100644 --- a/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBoltTest.java +++ b/external/urlfrontier/src/test/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBoltTest.java @@ -157,6 +157,27 @@ void canAckSimpleTupleWithMetadata() Assertions.assertEquals(true, isAcked(url, 5)); } + @Test + @Timeout(value = 2, unit = TimeUnit.MINUTES) + void emitsKeyAndMetadataOnQueueStream() { + final var url = "https://www.url.net/something"; + final var meta = new Metadata(); + meta.setValue(persistedKey, "somePersistedMetaInfo"); + store(url, Status.DISCOVERED, meta); + Assertions.assertEquals(true, isAcked(url, 5)); + + // a (key, metadata) tuple goes out on the queue stream, as in the + // OpenSearch StatusUpdaterBolt (#1974) + var emitted = output.getEmitted(org.apache.stormcrawler.Constants.QUEUE_STREAM_NAME); + Assertions.assertEquals(1, emitted.size()); + var values = emitted.get(0); + Assertions.assertEquals(2, values.size()); + Assertions.assertEquals("www.url.net", values.get(0)); + Assertions.assertTrue(values.get(1) instanceof Metadata); + Assertions.assertEquals( + "somePersistedMetaInfo", ((Metadata) values.get(1)).getFirstValue(persistedKey)); + } + @Test @Timeout(value = 2, unit = TimeUnit.MINUTES) void exceedingMaxMessagesInFlightAfterFrontierRestart()