diff --git a/AGENTS.md b/AGENTS.md
index 8b7069a..1c919ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -143,7 +143,7 @@ Examples:
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **hydrant** (655 symbols, 1810 relationships, 55 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **hydrant** (1339 symbols, 3645 relationships, 113 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/Cargo.lock b/Cargo.lock
index ad75b5e..6e0c282 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1491,6 +1491,7 @@ dependencies = [
"miette",
"mimalloc",
"multibase",
+ "parking_lot",
"rand 0.10.0",
"reqwest",
"rmp-serde",
diff --git a/Cargo.toml b/Cargo.toml
index 61f9c0e..5df713a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,6 +58,7 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] }
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
multibase = "0.9.2"
sha2 = "0.10.9"
+parking_lot = "0.12.5"
[dev-dependencies]
tempfile = "3.26.0"
diff --git a/README.md b/README.md
index 8f5e4e6..28bd11b 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-> [hydrant](#hydrant)
-> [vs tap](#vs-tap) | [stream](#stream-behavior) | [multi-relay](#multiple-relay-support) | [crawler sources](#crawler-sources)
--> [configuration](#configuration)
+-> [configuration](#configuration) | [build features](#build-features)
-> [rest api](#rest-api) | [filter](#filter-management) | [ingestion](#ingestion-control) | [crawler](#crawler-management) | [firehose](#firehose-management) | [repos](#repository-management)
-> [xrpc api](#data-access-xrpc) | [backlinks](#bluemicrocosmlinks) | [identity](#bluemicrocosmidentity) | [atproto](#comatproto) | [custom](#systemsgazehydrant)
@@ -142,6 +142,18 @@ directory, it will also be loaded automatically.
| `CRAWLER_MAX_PENDING_REPOS` | `2000` | max pending repos for crawler. |
| `CRAWLER_RESUME_PENDING_REPOS` | `1000` | resume threshold for crawler pending repos. |
+## build features
+
+[<- back to toc](#table-of-contents)
+
+`hydrant` has several optional compile-time features:
+
+| feature | default | description |
+| :--- | :--- | :--- |
+| `indexer` | yes | enables the indexing logic. |
+| `relay` | no | enables relay functionality. |
+| `backlinks` | no | enables the backlinks indexer and XRPC endpoints (`blue.microcosm.links.*`). |
+
## REST api
[<- back to toc](#table-of-contents)
@@ -329,6 +341,8 @@ the following are implemented currently:
- `com.atproto.sync.getRepoStatus`
- `com.atproto.sync.listRepos`
- `com.atproto.sync.getLatestCommit`
+- `com.atproto.sync.requestCrawl` (adds the host to firehose sources in relay mode)
+- `com.atproto.sync.subscribeRepos` (WebSocket firehose stream, requires `relay` feature)
### systems.gaze.hydrant.*
@@ -397,11 +411,3 @@ return the number of records that link to a given subject.
| `source` | no | filter by source collection (same format as `getBacklinks`). |
returns `{ count }`.
-
-### blue.microcosm.identity.*
-
-[<- back to toc](#table-of-contents)
-
-#### blue.microcosm.identity.resolveMiniDoc
-
-see [here](https://slingshot.microcosm.blue/#tag/slingshot-specific-queries/GET/xrpc/blue.microcosm.identity.resolveMiniDoc) for this XRPC's documentation.
diff --git a/src/api/filter.rs b/src/api/filter.rs
index 5bf9312..00503c0 100644
--- a/src/api/filter.rs
+++ b/src/api/filter.rs
@@ -1,5 +1,6 @@
use crate::control::{FilterPatch, Hydrant};
-use crate::filter::{FilterMode, SetUpdate};
+use crate::filter::FilterMode;
+use crate::patch::SetUpdate;
use axum::{
Json, Router,
extract::State,
diff --git a/src/api/mod.rs b/src/api/mod.rs
index f4299bb..a08cfac 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -11,6 +11,7 @@ mod debug;
mod filter;
mod firehose;
mod ingestion;
+mod pds;
mod repos;
mod stats;
#[cfg(feature = "indexer")]
@@ -27,6 +28,7 @@ pub async fn serve(hydrant: Hydrant, port: u16) -> miette::Result<()> {
let app = app
.merge(xrpc::router())
.merge(filter::router())
+ .merge(pds::router())
.merge(repos::router())
.merge(ingestion::router())
.merge(crawler::router())
diff --git a/src/api/pds.rs b/src/api/pds.rs
new file mode 100644
index 0000000..ce6baae
--- /dev/null
+++ b/src/api/pds.rs
@@ -0,0 +1,74 @@
+use std::collections::HashMap;
+
+use axum::{
+ Json, Router,
+ extract::State,
+ http::StatusCode,
+ routing::{delete, get, put},
+};
+use serde::{Deserialize, Serialize};
+
+use crate::control::{Hydrant, PdsTierAssignment, PdsTierDefinition};
+
+pub fn router() -> Router {
+ Router::new()
+ .route("/pds/tiers", get(list_tiers))
+ .route("/pds/tiers", put(set_tier))
+ .route("/pds/tiers", delete(remove_tier))
+ .route("/pds/rate-tiers", get(list_rate_tiers))
+}
+
+/// combined response: tier assignments + available tier definitions.
+#[derive(Serialize)]
+pub struct TiersResponse {
+ pub assignments: Vec,
+ pub rate_tiers: HashMap,
+}
+
+pub async fn list_tiers(State(hydrant): State) -> Json {
+ Json(TiersResponse {
+ assignments: hydrant.pds.list_assignments().await,
+ rate_tiers: hydrant.pds.list_rate_tiers(),
+ })
+}
+
+pub async fn list_rate_tiers(
+ State(hydrant): State,
+) -> Json> {
+ Json(hydrant.pds.list_rate_tiers())
+}
+
+#[derive(Deserialize)]
+pub struct SetTierBody {
+ pub host: String,
+ pub tier: String,
+}
+
+pub async fn set_tier(
+ State(hydrant): State,
+ Json(body): Json,
+) -> Result {
+ hydrant
+ .pds
+ .set_tier(body.host, body.tier)
+ .await
+ .map(|_| StatusCode::OK)
+ .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
+}
+
+#[derive(Deserialize)]
+pub struct RemoveTierBody {
+ pub host: String,
+}
+
+pub async fn remove_tier(
+ State(hydrant): State,
+ Json(body): Json,
+) -> Result {
+ hydrant
+ .pds
+ .remove_tier(body.host)
+ .await
+ .map(|_| StatusCode::OK)
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
+}
diff --git a/src/api/xrpc/mod.rs b/src/api/xrpc/mod.rs
index b098376..a717555 100644
--- a/src/api/xrpc/mod.rs
+++ b/src/api/xrpc/mod.rs
@@ -1,18 +1,10 @@
-use crate::api::xrpc::count_records::CountRecords;
-use crate::api::xrpc::describe_repo::DescribeRepo;
use crate::control::Hydrant;
use axum::extract::FromRequest;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router, extract::State, http::StatusCode};
-use jacquard_api::com_atproto::repo::{
- describe_repo::DescribeRepoRequest as AtprotoDescribeRepoRequest,
- get_record::{GetRecordError, GetRecordOutput, GetRecordRequest},
- list_records::{ListRecordsOutput, ListRecordsRequest, Record as RepoRecord},
-};
use jacquard_api::com_atproto::sync::get_host_status::GetHostStatusRequest;
use jacquard_api::com_atproto::sync::get_latest_commit::GetLatestCommitRequest;
-use jacquard_api::com_atproto::sync::get_repo::GetRepoRequest;
use jacquard_api::com_atproto::sync::get_repo_status::GetRepoStatusRequest;
use jacquard_api::com_atproto::sync::list_hosts::ListHostsRequest;
use jacquard_api::com_atproto::sync::list_repos::ListReposRequest;
@@ -27,48 +19,79 @@ use jacquard_common::{
use serde::{Deserialize, Serialize};
use smol_str::ToSmolStr;
use std::fmt::Display;
+use std::result::Result;
+#[cfg(feature = "indexer")]
+use {
+ crate::api::xrpc::count_records::CountRecords,
+ crate::api::xrpc::describe_repo::DescribeRepo,
+ jacquard_api::com_atproto::repo::{
+ describe_repo::DescribeRepoRequest as AtprotoDescribeRepoRequest,
+ get_record::{GetRecordError, GetRecordOutput, GetRecordRequest},
+ list_records::{ListRecordsOutput, ListRecordsRequest, Record as RepoRecord},
+ },
+ jacquard_api::com_atproto::sync::get_repo::GetRepoRequest,
+};
#[cfg(feature = "relay")]
use {
+ jacquard_api::com_atproto::sync::request_crawl::RequestCrawlRequest,
jacquard_api::com_atproto::sync::subscribe_repos::SubscribeReposEndpoint,
jacquard_common::xrpc::SubscriptionEndpoint,
};
+mod get_host_status;
+mod get_latest_commit;
+mod get_repo_status;
+mod list_hosts;
+mod list_repos;
+
+#[cfg(feature = "indexer")]
mod com_atproto_describe_repo;
+#[cfg(feature = "indexer")]
mod count_records;
+#[cfg(feature = "indexer")]
mod describe_repo;
-mod get_host_status;
-mod get_latest_commit;
+#[cfg(feature = "indexer")]
mod get_record;
+#[cfg(feature = "indexer")]
mod get_repo;
-mod get_repo_status;
-mod list_hosts;
+#[cfg(feature = "indexer")]
mod list_records;
-mod list_repos;
+
+#[cfg(feature = "relay")]
+mod request_crawl;
#[cfg(feature = "relay")]
mod subscribe_repos;
pub fn router() -> Router {
let r = Router::new()
+ .route(GetHostStatusRequest::PATH, get(get_host_status::handle))
+ .route(ListHostsRequest::PATH, get(list_hosts::handle))
+ .route(GetLatestCommitRequest::PATH, get(get_latest_commit::handle))
+ .route(GetRepoStatusRequest::PATH, get(get_repo_status::handle))
+ .route(ListReposRequest::PATH, get(list_repos::handle));
+
+ #[cfg(feature = "indexer")]
+ let r = r
.route(GetRecordRequest::PATH, get(get_record::handle))
.route(ListRecordsRequest::PATH, get(list_records::handle))
.route(CountRecords::PATH, get(count_records::handle))
+ .route(GetRepoRequest::PATH, get(get_repo::handle))
.route(DescribeRepo::PATH, get(describe_repo::handle))
.route(
AtprotoDescribeRepoRequest::PATH,
get(com_atproto_describe_repo::handle),
- )
- .route(GetHostStatusRequest::PATH, get(get_host_status::handle))
- .route(ListHostsRequest::PATH, get(list_hosts::handle))
- .route(GetLatestCommitRequest::PATH, get(get_latest_commit::handle))
- .route(GetRepoRequest::PATH, get(get_repo::handle))
- .route(GetRepoStatusRequest::PATH, get(get_repo_status::handle))
- .route(ListReposRequest::PATH, get(list_repos::handle));
+ );
#[cfg(feature = "relay")]
- let r = r.route(
- SubscribeReposEndpoint::PATH,
- axum::routing::get(subscribe_repos::handle),
- );
+ let r = r
+ .route(
+ SubscribeReposEndpoint::PATH,
+ axum::routing::get(subscribe_repos::handle),
+ )
+ .route(
+ RequestCrawlRequest::PATH,
+ axum::routing::get(subscribe_repos::handle),
+ );
r
}
diff --git a/src/api/xrpc/request_crawl.rs b/src/api/xrpc/request_crawl.rs
new file mode 100644
index 0000000..c52d0be
--- /dev/null
+++ b/src/api/xrpc/request_crawl.rs
@@ -0,0 +1,24 @@
+use jacquard_api::com_atproto::sync::request_crawl::{
+ RequestCrawlError, RequestCrawlRequest, RequestCrawlResponse,
+};
+use url::Url;
+
+use super::*;
+
+pub async fn handle(
+ State(hydrant): State,
+ ExtractXrpc(req): ExtractXrpc,
+) -> XrpcResult> {
+ let nsid = RequestCrawlResponse::NSID;
+
+ let url_str = format!("wss://{}/", req.hostname);
+ let url = Url::parse(&url_str).map_err(|e| bad_request(nsid, e))?;
+
+ hydrant
+ .firehose
+ .add_source(url, true)
+ .await
+ .map_err(|e| internal_error(nsid, e))?;
+
+ Ok(StatusCode::OK)
+}
diff --git a/src/config.rs b/src/config.rs
index 8a4a3d0..06fe879 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,12 +1,65 @@
use miette::Result;
use serde::{Deserialize, Serialize};
use smol_str::ToSmolStr;
+use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use url::Url;
+/// rate limit parameters for a named tier of PDS connections.
+///
+/// the per-second limit is `max(per_second_base, accounts * per_second_account_mul)`,
+/// giving a floor at `per_second_base` that scales up with the PDS's active account count.
+#[derive(Debug, Clone, Copy)]
+pub struct RateTier {
+ /// floor for the per-second limit, regardless of account count.
+ pub per_second_base: u64,
+ /// per-second events allowed per active account on this PDS.
+ pub per_second_account_mul: f64,
+ /// per-hour limit.
+ pub per_hour: u64,
+ /// per-day limit.
+ pub per_day: u64,
+}
+
+impl RateTier {
+ /// built-in "trusted" tier: high limits for well-behaved PDS operators.
+ pub fn trusted() -> Self {
+ Self {
+ per_second_base: 5000,
+ per_second_account_mul: 10.0,
+ per_hour: 5000 * 3600,
+ per_day: 5000 * 86400,
+ }
+ }
+
+ /// built-in "default" tier: conservative limits for unknown PDS operators.
+ pub fn default_tier() -> Self {
+ Self {
+ per_second_base: 50,
+ per_second_account_mul: 0.5,
+ per_hour: 1000 * 3600,
+ per_day: 1000 * 86400,
+ }
+ }
+
+ /// parse `base/mul/hourly/daily` format used by `HYDRANT_RATE_TIERS`.
+ fn parse(s: &str) -> Option {
+ let parts: Vec<&str> = s.split('/').collect();
+ if parts.len() != 4 {
+ return None;
+ }
+ Some(Self {
+ per_second_base: parts[0].parse().ok()?,
+ per_second_account_mul: parts[1].parse().ok()?,
+ per_hour: parts[2].parse().ok()?,
+ per_day: parts[3].parse().ok()?,
+ })
+ }
+}
+
/// this is for internal use only, please don't use this macro.
#[doc(hidden)]
#[macro_export]
@@ -309,6 +362,17 @@ pub struct Config {
/// set via `HYDRANT_ENABLE_BACKLINKS=true`.
pub enable_backlinks: bool,
+ /// list of trusted PDS/relay hosts to pre-assign to the "trusted" rate tier at startup.
+ /// set via `HYDRANT_TRUSTED_HOSTS` as a comma-separated list of hostnames.
+ /// hosts not present in this list use the "default" tier unless assigned via the API.
+ pub trusted_hosts: Vec,
+ /// named rate tier definitions for PDS rate limiting.
+ ///
+ /// built-in tiers ("default" and "trusted") are always present and may be overridden.
+ /// set via `HYDRANT_RATE_TIERS` as a comma-separated list of `name:base/mul/hourly/daily` entries,
+ /// e.g. `trusted:5000/10.0/18000000/432000000,custom:100/1.0/7200000/172800000`.
+ pub rate_tiers: HashMap,
+
/// db internals, tune only if you know what you're doing.
///
/// size of the fjall block cache in MB. set via `HYDRANT_CACHE_SIZE`.
@@ -388,6 +452,13 @@ impl Default for Config {
filter_collections: None,
filter_excludes: None,
enable_backlinks: false,
+ trusted_hosts: vec![],
+ rate_tiers: {
+ let mut m = HashMap::new();
+ m.insert("default".to_string(), RateTier::default_tier());
+ m.insert("trusted".to_string(), RateTier::trusted());
+ m
+ },
cache_size: 256,
data_compression: Compression::Lz4,
journal_compression: Compression::Lz4,
@@ -549,6 +620,35 @@ impl Config {
let enable_backlinks: bool = cfg!("ENABLE_BACKLINKS", defaults.enable_backlinks);
+ // start with built-in tiers, then layer in any env-defined overrides.
+ // format: HYDRANT_RATE_TIERS=name:base/mul/hourly/daily,...
+ let mut rate_tiers = defaults.rate_tiers.clone();
+ if let Ok(s) = std::env::var("HYDRANT_RATE_TIERS") {
+ for entry in s.split(',') {
+ let entry = entry.trim();
+ if let Some((name, spec)) = entry.split_once(':') {
+ match RateTier::parse(spec) {
+ Some(tier) => {
+ rate_tiers.insert(name.trim().to_string(), tier);
+ }
+ None => tracing::warn!(
+ "ignoring invalid rate tier '{name}': expected base/mul/hourly/daily format"
+ ),
+ }
+ }
+ }
+ }
+
+ let trusted_hosts = std::env::var("HYDRANT_TRUSTED_HOSTS")
+ .ok()
+ .map(|s| {
+ s.split(',')
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect()
+ })
+ .unwrap_or_else(|| defaults.trusted_hosts.clone());
+
let default_mode = CrawlerMode::default_for(full_network);
let crawler_sources = match std::env::var("HYDRANT_CRAWLER_URLS") {
Ok(s) => s
@@ -593,6 +693,8 @@ impl Config {
filter_collections,
filter_excludes,
enable_backlinks,
+ trusted_hosts,
+ rate_tiers,
cache_size,
data_compression,
journal_compression,
diff --git a/src/control/crawler.rs b/src/control/crawler.rs
index 4202d46..3b59b85 100644
--- a/src/control/crawler.rs
+++ b/src/control/crawler.rs
@@ -149,19 +149,20 @@ impl CrawlerHandle {
/// delete all cursor entries associated with the given URL.
pub async fn reset_cursor(&self, url: &str) -> Result<()> {
- let db = self.state.db.clone();
+ let state = self.state.clone();
let point_keys = [keys::crawler_cursor_key(url)];
let by_collection_prefix = keys::by_collection_cursor_prefix(url);
tokio::task::spawn_blocking(move || {
- let mut batch = db.inner.batch();
+ let mut batch = state.db.inner.batch();
for k in point_keys {
- batch.remove(&db.cursors, k);
+ batch.remove(&state.db.cursors, k);
}
- for entry in db.cursors.prefix(&by_collection_prefix) {
+ for entry in state.db.cursors.prefix(&by_collection_prefix) {
let k = entry.key().into_diagnostic()?;
- batch.remove(&db.cursors, k);
+ batch.remove(&state.db.cursors, k);
}
- batch.commit().into_diagnostic()
+ batch.commit().into_diagnostic()?;
+ state.db.persist()
})
.await
.into_diagnostic()??;
@@ -198,12 +199,15 @@ impl CrawlerHandle {
miette::bail!("crawler not yet started: call Hydrant::run() first");
};
- let db = self.state.db.clone();
+ let state = self.state.clone();
let key = keys::crawler_source_key(source.url.as_str());
let val = rmp_serde::to_vec(&source.mode).into_diagnostic()?;
- tokio::task::spawn_blocking(move || db.crawler.insert(key, val).into_diagnostic())
- .await
- .into_diagnostic()??;
+ tokio::task::spawn_blocking(move || {
+ state.db.crawler.insert(key, val).into_diagnostic()?;
+ state.db.persist()
+ })
+ .await
+ .into_diagnostic()??;
let enabled_rx = self.state.crawler_enabled.subscribe();
let handle = spawn_crawler_producer(
@@ -249,11 +253,14 @@ impl CrawlerHandle {
// remove from DB if it was a persisted source
if self.persisted.remove_async(url).await.is_some() {
- let db = self.state.db.clone();
+ let state = self.state.clone();
let key = keys::crawler_source_key(url.as_str());
- tokio::task::spawn_blocking(move || db.crawler.remove(key).into_diagnostic())
- .await
- .into_diagnostic()??;
+ tokio::task::spawn_blocking(move || {
+ state.db.crawler.remove(key).into_diagnostic()?;
+ state.db.persist()
+ })
+ .await
+ .into_diagnostic()??;
}
Ok(true)
diff --git a/src/control/filter.rs b/src/control/filter.rs
index 93f6419..5a6ba8f 100644
--- a/src/control/filter.rs
+++ b/src/control/filter.rs
@@ -4,7 +4,8 @@ use tracing::error;
use miette::{IntoDiagnostic, Result};
use crate::db::filter as db_filter;
-use crate::filter::{FilterMode, SetUpdate};
+use crate::filter::FilterMode;
+use crate::patch::SetUpdate;
use crate::state::AppState;
/// a point-in-time snapshot of the filter configuration. returned by all [`FilterControl`] methods.
@@ -273,6 +274,7 @@ impl FilterPatch {
let filter_ks = self.state.db.filter.clone();
let inner = self.state.db.inner.clone();
let filter_handle = self.state.filter.clone();
+ let state = self.state.clone();
let mode = self.mode;
let signals = self.signals;
let collections = self.collections;
@@ -282,6 +284,7 @@ impl FilterPatch {
let mut batch = inner.batch();
db_filter::apply_patch(&mut batch, &filter_ks, mode, signals, collections, excludes)?;
batch.commit().into_diagnostic()?;
+ state.db.persist()?;
db_filter::load(&filter_ks)
})
.await
diff --git a/src/control/firehose.rs b/src/control/firehose.rs
index 80e7d55..24ab21e 100644
--- a/src/control/firehose.rs
+++ b/src/control/firehose.rs
@@ -1,7 +1,6 @@
use std::sync::Arc;
-use std::sync::atomic::Ordering;
-use miette::{Context, IntoDiagnostic, Result};
+use miette::{IntoDiagnostic, Result};
use tokio::sync::watch;
use tracing::{error, info};
use url::Url;
@@ -62,7 +61,8 @@ pub(super) async fn spawn_firehose_ingestor(
state.filter.clone(),
enabled,
shared.verify_signatures,
- );
+ )
+ .await;
let relay_for_log = relay_url.clone();
let abort = tokio::spawn(async move {
@@ -88,118 +88,124 @@ pub struct FirehoseHandle {
}
impl FirehoseHandle {
- /// enable the firehose. no-op if already enabled.
+ pub(super) fn new(state: Arc) -> Self {
+ Self {
+ state,
+ shared: Arc::new(std::sync::OnceLock::new()),
+ tasks: Arc::new(scc::HashMap::new()),
+ persisted: Arc::new(scc::HashSet::new()),
+ }
+ }
+
+ /// enable firehose ingestion, no-op if already enabled.
pub fn enable(&self) {
self.state.firehose_enabled.send_replace(true);
}
- /// disable the firehose. the current message finishes processing before the connection closes.
+ /// disable firehose ingestion, in-flight messages complete before pausing.
pub fn disable(&self) {
self.state.firehose_enabled.send_replace(false);
}
- /// returns the current enabled state of the firehose.
+ /// returns the current enabled state of firehose ingestion.
pub fn is_enabled(&self) -> bool {
*self.state.firehose_enabled.borrow()
}
- /// reset the stored cursor for the given relay URL.
- ///
- /// clears the `firehose_cursor|{host}|{scheme}` entry from the cursors keyspace and zeroes
- /// the in-memory cursor. the next connection will tail live events from the current head.
- pub async fn reset_cursor(&self, url: &str) -> Result<()> {
- let relay_url = Url::parse(url)
- .into_diagnostic()
- .wrap_err_with(|| format!("invalid relay url: {url:?}"))?;
- let key = keys::firehose_cursor_key_from_url(&relay_url);
- let db = self.state.db.clone();
- tokio::task::spawn_blocking(move || db.cursors.remove(key).into_diagnostic())
- .await
- .into_diagnostic()??;
-
- self.state.firehose_cursors.peek_with(&relay_url, |_, c| {
- c.store(0, Ordering::SeqCst);
- });
- Ok(())
- }
-
- /// return info on all currently active firehose sources.
+ /// list all currently active firehose sources.
pub async fn list_sources(&self) -> Vec {
- let mut sources = Vec::new();
+ let mut out = Vec::new();
self.tasks
- .iter_async(|url, handle| {
- sources.push(FirehoseSourceInfo {
+ .any_async(|url, handle| {
+ out.push(FirehoseSourceInfo {
url: url.clone(),
persisted: self.persisted.contains_sync(url),
is_pds: handle.is_pds,
});
- true
+ false
})
.await;
- sources
+ out
}
- /// add a new firehose relay at runtime.
- ///
- /// the URL is persisted to the database and will be re-spawned on restart. if a relay with
- /// the same URL already exists it is replaced: the running task is stopped and a new one
- /// is started. any cursor state for that URL is preserved.
+ /// add a new firehose source at runtime, persisting it to the database.
///
- /// returns an error if called before [`Hydrant::run`].
+ /// if a source with the same URL already exists, it is replaced: the
+ /// running task is stopped and a new one is started with the new `is_pds`
+ /// setting. existing cursor state for the URL is preserved.
pub async fn add_source(&self, url: Url, is_pds: bool) -> Result<()> {
- let Some(shared) = self.shared.get() else {
- miette::bail!("firehose not yet started: call Hydrant::run() first");
- };
+ let shared = self
+ .shared
+ .get()
+ .ok_or_else(|| miette::miette!("firehose worker not started"))?;
- let db = self.state.db.clone();
+ // persist to db first
let key = keys::firehose_source_key(url.as_str());
- let value = rmp_serde::to_vec(&crate::db::FirehoseSourceMeta { is_pds })
- .map_err(|e| miette::miette!("failed to serialize firehose source meta: {e}"))?;
- tokio::task::spawn_blocking(move || db.crawler.insert(key, value).into_diagnostic())
- .await
- .into_diagnostic()??;
+ tokio::task::spawn_blocking({
+ let state = self.state.clone();
+ move || {
+ let mut batch = state.db.inner.batch();
+ let value = rmp_serde::to_vec(&db::FirehoseSourceMeta { is_pds }).map_err(|e| {
+ miette::miette!("failed to serialize firehose source meta: {e}")
+ })?;
+ batch.insert(&state.db.crawler, key, &value);
+ batch.commit().into_diagnostic()?;
+ state.db.persist()
+ }
+ })
+ .await
+ .into_diagnostic()??;
+
+ let _ = self.persisted.insert_async(url.clone()).await;
let enabled_rx = self.state.firehose_enabled.subscribe();
let handle = spawn_firehose_ingestor(&url, is_pds, &self.state, shared, enabled_rx).await?;
+ self.tasks.upsert_async(url, handle).await;
- let _ = self.persisted.insert_async(url.clone()).await;
- match self.tasks.entry_async(url).await {
- scc::hash_map::Entry::Vacant(e) => {
- e.insert_entry(handle);
- }
- scc::hash_map::Entry::Occupied(mut e) => {
- *e.get_mut() = handle;
- }
- }
Ok(())
}
- /// remove a firehose relay at runtime by URL.
- ///
- /// aborts the running ingestor task. if the source was added via the API it is removed from
- /// the database and will not reappear on restart. `RELAY_HOSTS` sources are only stopped for
- /// the current session; they reappear on the next restart.
+ /// remove a firehose source at runtime.
///
- /// returns `true` if the relay was found and removed, `false` if it was not running.
- /// returns an error if called before [`Hydrant::run`].
+ /// returns `true` if the source was found and removed, `false` otherwise.
+ /// if the source was added via the API, it is removed from the database;
+ /// if it came from the static config, only the running task is stopped.
pub async fn remove_source(&self, url: &Url) -> Result {
- if self.shared.get().is_none() {
- miette::bail!("firehose not yet started: call Hydrant::run() first");
+ if self.persisted.contains_async(url).await {
+ let url_str = url.to_string();
+ tokio::task::spawn_blocking({
+ let state = self.state.clone();
+ move || {
+ state
+ .db
+ .crawler
+ .remove(keys::firehose_source_key(&url_str))
+ .into_diagnostic()?;
+ state.db.persist()
+ }
+ })
+ .await
+ .into_diagnostic()??;
+ self.persisted.remove_async(url).await;
}
- if self.tasks.remove_async(url).await.is_none() {
- return Ok(false);
- }
+ Ok(self.tasks.remove_async(url).await.is_some())
+ }
- // remove from relay_cursors (persist thread will stop tracking it)
- self.state.firehose_cursors.remove_async(url).await;
+ /// reset the stored firehose cursor for a given URL.
+ pub async fn reset_cursor(&self, url: &str) -> Result<()> {
+ let url = Url::parse(url).into_diagnostic()?;
+ let key = keys::firehose_cursor_key_from_url(&url);
+ tokio::task::spawn_blocking({
+ let state = self.state.clone();
+ move || {
+ state.db.cursors.remove(key).into_diagnostic()?;
+ state.db.persist()
+ }
+ })
+ .await
+ .into_diagnostic()??;
- if self.persisted.remove_async(url).await.is_some() {
- let db = self.state.db.clone();
- let key = keys::firehose_source_key(url.as_str());
- tokio::task::spawn_blocking(move || db.crawler.remove(key).into_diagnostic())
- .await
- .into_diagnostic()??;
- }
+ self.state.firehose_cursors.remove_async(&url).await;
- Ok(true)
+ Ok(())
}
}
diff --git a/src/control/mod.rs b/src/control/mod.rs
index 3ae9010..a1d507d 100644
--- a/src/control/mod.rs
+++ b/src/control/mod.rs
@@ -3,12 +3,14 @@
pub(crate) mod crawler;
pub(crate) mod filter;
pub(crate) mod firehose;
+pub(crate) mod pds;
pub(crate) mod repos;
pub(crate) mod stream;
pub use crawler::{CrawlerHandle, CrawlerSourceInfo};
pub use filter::{FilterControl, FilterPatch, FilterSnapshot};
pub use firehose::{FirehoseHandle, FirehoseSourceInfo};
+pub use pds::{PdsControl, PdsTierAssignment, PdsTierDefinition};
pub use repos::{ListedRecord, Record, RecordList, RepoHandle, RepoInfo, ReposControl};
use smol_str::{SmolStr, ToSmolStr};
@@ -87,6 +89,7 @@ pub struct Hydrant {
pub firehose: FirehoseHandle,
pub backfill: BackfillHandle,
pub filter: FilterControl,
+ pub pds: PdsControl,
pub repos: ReposControl,
pub db: DbControl,
#[cfg(feature = "backlinks")]
@@ -121,15 +124,15 @@ impl Hydrant {
let signals = config
.filter_signals
.clone()
- .map(crate::filter::SetUpdate::Set);
+ .map(crate::patch::SetUpdate::Set);
let collections = config
.filter_collections
.clone()
- .map(crate::filter::SetUpdate::Set);
+ .map(crate::patch::SetUpdate::Set);
let excludes = config
.filter_excludes
.clone()
- .map(crate::filter::SetUpdate::Set);
+ .map(crate::patch::SetUpdate::Set);
tokio::task::spawn_blocking(move || {
let mut batch = inner.batch();
@@ -174,14 +177,10 @@ impl Hydrant {
tasks: Arc::new(scc::HashMap::new()),
persisted: Arc::new(scc::HashSet::new()),
},
- firehose: FirehoseHandle {
- state: state.clone(),
- shared: Arc::new(std::sync::OnceLock::new()),
- tasks: Arc::new(scc::HashMap::new()),
- persisted: Arc::new(scc::HashSet::new()),
- },
+ firehose: FirehoseHandle::new(state.clone()),
backfill: BackfillHandle(state.clone()),
filter: FilterControl(state.clone()),
+ pds: pds::PdsControl(state.clone()),
repos: ReposControl(state.clone()),
db: DbControl(state.clone()),
#[cfg(feature = "backlinks")]
@@ -436,10 +435,10 @@ impl Hydrant {
// 11. spawn crawler infrastructure
#[cfg(feature = "indexer")]
{
- use crate::crawler::throttle::Throttler;
use crate::crawler::{
CrawlerStats, CrawlerWorker, InFlight, RetryProducer, SignalChecker,
};
+ use crate::util::throttle::Throttler;
let http = reqwest::Client::builder()
.user_agent(concat!(
@@ -450,7 +449,7 @@ impl Hydrant {
.gzip(true)
.build()
.expect("that reqwest will build");
- let pds_throttler = Throttler::new();
+ let pds_throttler = state.throttler.clone();
let in_flight = InFlight::new();
let stats = CrawlerStats::new(
state.clone(),
@@ -714,7 +713,7 @@ impl Hydrant {
///
/// sizes are in bytes, reported per keyspace.
pub async fn stats(&self) -> Result {
- let db = self.state.db.clone();
+ let state = self.state.clone();
let mut counts: BTreeMap<&'static str, u64> = futures::future::join_all(
[
@@ -729,29 +728,29 @@ impl Hydrant {
]
.into_iter()
.map(|name| {
- let db = db.clone();
- async move { (name, db.get_count(name).await) }
+ let state = state.clone();
+ async move { (name, state.db.get_count(name).await) }
}),
)
.await
.into_iter()
.collect();
- counts.insert("events", db.events.approximate_len() as u64);
+ counts.insert("events", state.db.events.approximate_len() as u64);
let sizes = tokio::task::spawn_blocking(move || {
let mut s = BTreeMap::new();
- s.insert("repos", db.repos.disk_space());
- s.insert("records", db.records.disk_space());
- s.insert("blocks", db.blocks.disk_space());
- s.insert("cursors", db.cursors.disk_space());
- s.insert("pending", db.pending.disk_space());
- s.insert("resync", db.resync.disk_space());
- s.insert("resync_buffer", db.resync_buffer.disk_space());
- s.insert("events", db.events.disk_space());
- s.insert("counts", db.counts.disk_space());
- s.insert("filter", db.filter.disk_space());
- s.insert("crawler", db.crawler.disk_space());
+ s.insert("repos", state.db.repos.disk_space());
+ s.insert("records", state.db.records.disk_space());
+ s.insert("blocks", state.db.blocks.disk_space());
+ s.insert("cursors", state.db.cursors.disk_space());
+ s.insert("pending", state.db.pending.disk_space());
+ s.insert("resync", state.db.resync.disk_space());
+ s.insert("resync_buffer", state.db.resync_buffer.disk_space());
+ s.insert("events", state.db.events.disk_space());
+ s.insert("counts", state.db.counts.disk_space());
+ s.insert("filter", state.db.filter.disk_space());
+ s.insert("crawler", state.db.crawler.disk_space());
s
})
.await
@@ -788,12 +787,12 @@ impl Hydrant {
///
/// returns the seq we are on for this host.
pub async fn get_host_status(&self, hostname: &str) -> Result