use super::{Crawler, CrawlerKind, CrawlerShared, engine::EngineOptions};
use crate::{
autoscale::{AutoscaleMode, AutoscaledPool, AutoscaledPoolOptions},
config::{ConfigError, Configuration},
handler::{FailedRequestHandler, RequestHandler},
link_extraction::CrawlPolicy,
storage::{KeyValueStore, RequestQueue, StorageClient, StorageError},
};
use std::{sync::Arc, time::Duration};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CrawlerBuildError {
#[error("a request handler is required")]
MissingRequestHandler,
#[error(
"no storage client configured (set .storage_client(...) or Configuration::storage_client)"
)]
MissingStorage,
#[error("max_concurrency must be at least 1")]
ZeroMaxConcurrency,
#[error(
"dynamic scheduler options require valid concurrency bounds and a non-zero maybe_run_interval"
)]
InvalidConcurrencyBounds,
#[error("results_capacity must be at least 1")]
ZeroResultsCapacity,
#[error("configuration: {0}")]
Config(#[from] ConfigError),
#[error("storage: {0}")]
Storage(#[from] StorageError),
}
#[must_use = "builders do nothing unless consumed by build"]
pub struct CrawlerBuilder<K: CrawlerKind> {
kind: K,
handler: Option<Arc<dyn RequestHandler<K::Context>>>,
failed_handler: Option<Arc<dyn FailedRequestHandler>>,
autoscaled_pool: AutoscaledPoolOptions,
max_request_retries: u32,
max_session_rotations: u32,
request_handler_timeout: Duration,
internal_operation_timeout: Duration,
configuration: Option<Configuration>,
storage_client: Option<Arc<dyn StorageClient>>,
request_queue: Option<Arc<dyn RequestQueue>>,
results_capacity: usize,
retry_strategy: Option<Arc<dyn crate::retry_strategy::RetryStrategy>>,
crawl_policy: Option<Arc<CrawlPolicy>>,
}
impl<K: CrawlerKind> CrawlerBuilder<K> {
pub fn new(kind: K) -> Self {
Self {
kind,
handler: None,
failed_handler: None,
autoscaled_pool: AutoscaledPoolOptions {
fixed_concurrency: Some(10),
max_concurrency: 10,
..Default::default()
},
max_request_retries: 3,
max_session_rotations: 10,
request_handler_timeout: Duration::from_secs(60),
internal_operation_timeout: Duration::from_secs(30),
configuration: None,
storage_client: None,
request_queue: None,
results_capacity: 1024,
retry_strategy: None,
crawl_policy: None,
}
}
pub fn request_handler<H: RequestHandler<K::Context>>(mut self, handler: H) -> Self {
self.handler = Some(Arc::new(handler));
self
}
pub fn failed_request_handler<H: FailedRequestHandler>(mut self, handler: H) -> Self {
self.failed_handler = Some(Arc::new(handler));
self
}
pub fn max_concurrency(mut self, count: usize) -> Self {
self.autoscaled_pool.fixed_concurrency = Some(count);
self.autoscaled_pool.max_concurrency = count;
self
}
pub fn min_concurrency(mut self, count: usize) -> Self {
self.autoscaled_pool.min_concurrency = count;
self
}
pub fn desired_concurrency(mut self, count: usize) -> Self {
self.autoscaled_pool.desired_concurrency = Some(count);
self
}
pub fn autoscale_mode(mut self, mode: AutoscaleMode) -> Self {
self.autoscaled_pool.mode = mode;
self.autoscaled_pool.fixed_concurrency = None;
self
}
pub fn max_tasks_per_minute(mut self, count: u32) -> Self {
self.autoscaled_pool.max_tasks_per_minute = Some(count);
self
}
pub fn same_domain_delay(mut self, delay: Duration) -> Self {
self.autoscaled_pool.same_domain_delay = delay;
self
}
pub fn autoscaled_pool_options(mut self, options: AutoscaledPoolOptions) -> Self {
self.autoscaled_pool = options;
self
}
pub fn max_request_retries(mut self, count: u32) -> Self {
self.max_request_retries = count;
self
}
pub fn max_session_rotations(mut self, count: u32) -> Self {
self.max_session_rotations = count;
self
}
pub fn request_handler_timeout(mut self, timeout: Duration) -> Self {
self.request_handler_timeout = timeout;
self
}
pub fn internal_operation_timeout(mut self, timeout: Duration) -> Self {
self.internal_operation_timeout = timeout;
self
}
pub fn configuration(mut self, configuration: Configuration) -> Self {
self.configuration = Some(configuration);
self
}
pub fn storage_client(mut self, storage: Arc<dyn StorageClient>) -> Self {
self.storage_client = Some(storage);
self
}
pub fn request_queue(mut self, queue: Arc<dyn RequestQueue>) -> Self {
self.request_queue = Some(queue);
self
}
pub fn results_capacity(mut self, capacity: usize) -> Self {
self.results_capacity = capacity;
self
}
pub fn retry_strategy<S: crate::retry_strategy::RetryStrategy>(mut self, strategy: S) -> Self {
self.retry_strategy = Some(Arc::new(strategy));
self
}
pub fn crawl_policy(mut self, policy: CrawlPolicy) -> Self {
self.crawl_policy = Some(Arc::new(policy));
self
}
pub async fn build(self) -> Result<Crawler<K>, CrawlerBuildError> {
let handler = self
.handler
.ok_or(CrawlerBuildError::MissingRequestHandler)?;
if self.autoscaled_pool.fixed_concurrency == Some(0) {
return Err(CrawlerBuildError::ZeroMaxConcurrency);
}
if (self.autoscaled_pool.fixed_concurrency.is_none()
&& (self.autoscaled_pool.min_concurrency < 1
|| self.autoscaled_pool.max_concurrency < self.autoscaled_pool.min_concurrency))
|| self.autoscaled_pool.maybe_run_interval.is_zero()
{
return Err(CrawlerBuildError::InvalidConcurrencyBounds);
}
if self.results_capacity == 0 {
return Err(CrawlerBuildError::ZeroResultsCapacity);
}
let config = match self.configuration {
Some(configuration) => configuration,
None => Configuration::builder().build()?,
};
let storage = self
.storage_client
.or_else(|| config.storage_client().cloned())
.ok_or(CrawlerBuildError::MissingStorage)?;
if config.purge_on_start() {
storage.purge().await?;
}
let queue = match self.request_queue {
Some(queue) => queue,
None => {
storage
.open_request_queue(Some(config.default_request_queue_id()))
.await?
}
};
let kvs: Option<Arc<dyn KeyValueStore>> = Some(
storage
.open_key_value_store(Some(config.default_key_value_store_id()))
.await?,
);
let task_timeout = self.autoscaled_pool.task_timeout;
let maybe_run_interval = self.autoscaled_pool.maybe_run_interval;
let opts = EngineOptions {
max_request_retries: self.max_request_retries,
max_session_rotations: self.max_session_rotations,
request_handler_timeout: self.request_handler_timeout,
internal_operation_timeout: self.internal_operation_timeout,
persist_state_interval: config.persist_state_interval(),
task_timeout,
maybe_run_interval,
retry_strategy: self.retry_strategy,
max_requests_per_crawl: self
.crawl_policy
.as_ref()
.and_then(|policy| policy.max_requests_per_crawl),
};
let pool = Arc::new(AutoscaledPool::new(self.autoscaled_pool));
let shared = Arc::new(CrawlerShared::new_with_policy(
queue,
config.events().clone(),
self.results_capacity,
self.internal_operation_timeout,
pool,
self.crawl_policy,
));
Ok(Crawler {
kind: Arc::new(self.kind),
shared,
config: Arc::new(config),
handler,
failed_handler: self.failed_handler,
kvs,
storage: Some(storage),
opts,
started: std::sync::atomic::AtomicBool::new(false),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
crawler::{BasicContext, BasicKind},
memory_client as millipede_storage_memory,
};
fn aimd() -> AutoscaleMode {
AutoscaleMode::Aimd {
increase_after_successes: 1,
decrease_factor: 0.5,
}
}
#[tokio::test]
async fn invalid_dynamic_bounds_are_rejected_before_storage_resolution() {
let zero_min = CrawlerBuilder::new(BasicKind)
.request_handler(|_ctx: BasicContext| async { Ok(()) })
.autoscale_mode(aimd())
.min_concurrency(0)
.build()
.await;
assert!(matches!(
zero_min,
Err(CrawlerBuildError::InvalidConcurrencyBounds)
));
let inverted = CrawlerBuilder::new(BasicKind)
.request_handler(|_ctx: BasicContext| async { Ok(()) })
.min_concurrency(2)
.max_concurrency(1)
.autoscale_mode(aimd())
.build()
.await;
assert!(matches!(
inverted,
Err(CrawlerBuildError::InvalidConcurrencyBounds)
));
}
#[tokio::test]
async fn zero_maybe_run_interval_is_rejected_before_storage_resolution() {
let result = CrawlerBuilder::new(BasicKind)
.request_handler(|_ctx: BasicContext| async { Ok(()) })
.autoscaled_pool_options(AutoscaledPoolOptions {
maybe_run_interval: Duration::ZERO,
..Default::default()
})
.build()
.await;
assert!(matches!(
result,
Err(CrawlerBuildError::InvalidConcurrencyBounds)
));
}
#[tokio::test]
async fn autoscale_mode_after_max_concurrency_keeps_dynamic_ceiling() {
let crawler = CrawlerBuilder::new(BasicKind)
.request_handler(|_ctx: BasicContext| async { Ok(()) })
.storage_client(Arc::new(
millipede_storage_memory::MemoryStorageClient::new(),
))
.max_concurrency(5)
.autoscale_mode(aimd())
.build()
.await
.unwrap();
let snapshot = crawler.autoscaler_snapshot();
assert!(!snapshot.is_fixed);
assert_eq!(snapshot.max_concurrency, 5);
}
#[tokio::test]
async fn max_concurrency_after_autoscale_mode_repins_to_fixed() {
let crawler = CrawlerBuilder::new(BasicKind)
.request_handler(|_ctx: BasicContext| async { Ok(()) })
.storage_client(Arc::new(
millipede_storage_memory::MemoryStorageClient::new(),
))
.autoscale_mode(aimd())
.max_concurrency(5)
.build()
.await
.unwrap();
let snapshot = crawler.autoscaler_snapshot();
assert!(snapshot.is_fixed);
assert_eq!(snapshot.desired_concurrency, 5);
}
}