Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ad7fd8f
feat: checkpoint
indietyp Jun 30, 2026
4c7c562
fix: suggestions from code review
indietyp Jul 1, 2026
c2c0728
fix: spawn blocking for clustering
indietyp Jul 1, 2026
a63a05a
fix: regenerate
indietyp Jul 1, 2026
c1c0f7f
fix: unnest sql query
indietyp Jul 1, 2026
3b5b26e
fix: lints
indietyp Jul 1, 2026
d1c214e
fix: lints
indietyp Jul 1, 2026
2249f1b
fix: openapi schema
indietyp Jul 1, 2026
9b7fef6
fix: docs
indietyp Jul 3, 2026
121cd22
fix: docs
indietyp Jul 3, 2026
9bffac9
feat: embedding clustering review
indietyp Jul 3, 2026
6865e31
chore: diagram
indietyp Jul 3, 2026
fa9b35a
chore: lockfile
indietyp Jul 3, 2026
2bfd8ba
chore: fix schema
indietyp Jul 3, 2026
3cc5897
feat: add scripts
indietyp Jul 6, 2026
7a8ddad
fix: warm up repository on CI
indietyp Jul 6, 2026
336dd2f
fix: docs
indietyp Jul 6, 2026
4685963
chore: bound the threads inside of benchmarking + thread pool limit
indietyp Jul 6, 2026
fa9c9da
feat: limit request + remove clustering unsafe
indietyp Jul 6, 2026
505d16a
fix: wording
indietyp Jul 6, 2026
7ebfe44
chore: regen openapi
indietyp Jul 6, 2026
60603e4
chore: docs
indietyp Jul 6, 2026
7c1dcbe
feat: change the accumulate clusters method
indietyp Jul 6, 2026
0138056
chore: docs
indietyp Jul 6, 2026
2dafd88
feat: move into embeddings crate
indietyp Jul 7, 2026
aff3317
fix: CI
indietyp Jul 7, 2026
eb38f87
chore: regenerate files
indietyp Jul 7, 2026
5f7fd58
fix: the darn yarn lockfile
indietyp Jul 7, 2026
77ada17
chore: remove tautological tests
indietyp Jul 7, 2026
02d2958
feat: only initialize once.
indietyp Jul 7, 2026
1d4a1f7
fix: docs
indietyp Jul 7, 2026
0bb8726
feat: move to rayon spawns
indietyp Jul 7, 2026
4119460
feat: test
indietyp Jul 7, 2026
3fd0938
chore: lockfile
indietyp Jul 7, 2026
464e3b0
fix: suggestions from (external) code review
indietyp Jul 8, 2026
0aa7d67
feat: dedupe query and make it deterministic
indietyp Jul 8, 2026
90538eb
fix: docs
indietyp Jul 8, 2026
50be867
fix: schema
indietyp Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ jobs:
with:
scope: ${{ matrix.name }}

- name: Warm up repository
uses: ./.github/actions/warm-up-repo

- name: Build the benchmark target
run: turbo run build:codspeed --filter=${{ matrix.name }}

Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ quote = { version = "1.0.41", default-features = fa
rand = { version = "0.10.0", default-features = false }
rand_core = { version = "0.10.0", default-features = false }
rand_distr = { version = "0.6.0", default-features = false }
rand_xoshiro = { version = "0.8.1" }
rapidfuzz = { version = "0.5.0", default-features = false }
ratatui = { version = "0.30.0" }
rayon = { version = "1.11.0", default-features = false }
Expand Down
1 change: 1 addition & 0 deletions apps/hash-graph/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ futures = { workspace = true }
jsonwebtoken = { workspace = true }
mimalloc = { workspace = true }
multiaddr = { workspace = true }
rayon = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true, features = ["rustls"] }
simple-mermaid = { workspace = true }
Expand Down
3 changes: 2 additions & 1 deletion apps/hash-graph/docs/dependency-diagram.mmd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion apps/hash-graph/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use clap::{
};
use hash_telemetry::TracingConfig;

use crate::subcommand::Subcommand;
use crate::subcommand::{Subcommand, WorkerThreads};

/// Arguments passed to the program.
#[derive(Debug, Parser)]
Expand All @@ -16,6 +16,19 @@ pub struct Args {
#[clap(flatten)]
pub tracing_config: TracingConfig,

/// Number of threads in the global worker pool used for CPU-bound work such as entity
/// clustering.
///
/// Accepts a fixed count (e.g. `4`) or a count relative to the available CPU cores: `n` for
/// all cores, `n/2` for half, `n/4` for a quarter, and so on.
#[clap(
long,
global = true,
default_value_t,
env = "HASH_GRAPH_WORKER_THREADS"
)]
pub worker_threads: WorkerThreads,

/// Specify a subcommand to run.
#[command(subcommand)]
pub subcommand: Subcommand,
Expand Down
3 changes: 2 additions & 1 deletion apps/hash-graph/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ fn main() -> Result<(), Report<GraphError>> {
let Args {
subcommand,
tracing_config,
worker_threads,
} = Args::parse_args();

let _sentry_guard = init(&tracing_config.sentry, release_name!());

subcommand.execute(tracing_config)
subcommand.execute(tracing_config, worker_threads)
}
168 changes: 141 additions & 27 deletions apps/hash-graph/src/subcommand/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ mod server;
mod snapshot;
mod type_fetcher;

use core::time::Duration;
use std::time::Instant;
use core::{fmt, num::NonZero, str::FromStr, time::Duration};
use std::{sync::Once, thread::available_parallelism, time::Instant};

use clap::Parser;
use error_stack::{Report, ensure};
use hash_telemetry::{TracingConfig, init_tracing};
use tokio::time::sleep;
use tokio_util::{sync::CancellationToken, task::TaskTracker};

pub use self::{
admin_server::{AdminServerArgs, admin_server},
completions::{CompletionsArgs, completions},
migrate::{MigrateArgs, migrate},
server::{ServerArgs, server},
snapshot::{SnapshotArgs, snapshot},
type_fetcher::{TypeFetcherArgs, type_fetcher},
};
use crate::{
error::{GraphError, HealthcheckError},
subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache},
};

/// Drop guard that fires the `abort` token when a server task exits unexpectedly.
///
/// "Unexpectedly" means the `shutdown` token has not been cancelled yet. This covers both
Expand Down Expand Up @@ -87,6 +100,85 @@ impl ServerLifecycle {
}
}

/// Number of threads for the global worker pool used for CPU-bound work.
///
/// Parses either a fixed thread count (e.g. `4`) or a count relative to the number of available
/// CPU cores: `n` for all cores, `n/2` for half of them, `n/4` for a quarter, and so on.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum WorkerThreads {
/// The available CPU cores divided by the given divisor (`n`, `n/2`, `n/4`, ...).
Cores { divisor: NonZero<usize> },
/// A fixed number of threads.
Fixed(NonZero<usize>),
}

impl WorkerThreads {
/// Resolves to a concrete thread count, clamped to at least one thread.
#[expect(
clippy::integer_division,
reason = "Deriving a thread count from the core count is inherently lossy."
)]
fn resolve(self) -> NonZero<usize> {
match self {
Self::Fixed(threads) => threads,
Self::Cores { divisor } => available_parallelism()
.ok()
.and_then(|cores| NonZero::new(cores.get() / divisor))
.unwrap_or(NonZero::<usize>::MIN),
Comment thread
indietyp marked this conversation as resolved.
Comment thread
indietyp marked this conversation as resolved.
Comment thread
indietyp marked this conversation as resolved.
}
}
}

impl Default for WorkerThreads {
fn default() -> Self {
const HALF: NonZero<usize> = NonZero::new(2).expect("two should be non-zero");
Self::Cores { divisor: HALF }
}
}

impl fmt::Display for WorkerThreads {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Cores { divisor } if divisor == NonZero::<usize>::MIN => fmt.write_str("n"),
Self::Cores { divisor } => write!(fmt, "n/{divisor}"),
Self::Fixed(threads) => write!(fmt, "{threads}"),
}
}
}

/// Error returned when parsing a [`WorkerThreads`] value fails.
#[derive(Debug)]
pub struct ParseWorkerThreadsError;

impl fmt::Display for ParseWorkerThreadsError {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt.write_str("expected a positive integer, `n`, or `n/<divisor>` (e.g. `4`, `n`, `n/2`)")
}
}

impl core::error::Error for ParseWorkerThreadsError {}

impl FromStr for WorkerThreads {
type Err = ParseWorkerThreadsError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.strip_prefix(['n', 'N']) {
Comment thread
indietyp marked this conversation as resolved.
Some("") => Ok(Self::Cores {
divisor: NonZero::<usize>::MIN,
}),
Some(rest) => rest
.strip_prefix('/')
.and_then(|divisor| divisor.parse().ok())
.map(|divisor| Self::Cores { divisor })
.ok_or(ParseWorkerThreadsError),
None => value
.parse()
.map(Self::Fixed)
.map_err(|_error: core::num::ParseIntError| ParseWorkerThreadsError),
}
}
}

/// Shared healthcheck arguments for all server subcommands.
#[derive(Debug, Clone, Parser)]
pub(crate) struct HealthcheckArgs {
Expand All @@ -103,19 +195,6 @@ pub(crate) struct HealthcheckArgs {
pub timeout: Option<u64>,
}

pub use self::{
admin_server::{AdminServerArgs, admin_server},
completions::{CompletionsArgs, completions},
migrate::{MigrateArgs, migrate},
server::{ServerArgs, server},
snapshot::{SnapshotArgs, snapshot},
type_fetcher::{TypeFetcherArgs, type_fetcher},
};
use crate::{
error::{GraphError, HealthcheckError},
subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache},
};

/// Subcommand for the program.
#[derive(Debug, clap::Subcommand)]
pub enum Subcommand {
Expand Down Expand Up @@ -145,7 +224,17 @@ fn block_on(
future: impl Future<Output = Result<(), Report<GraphError>>>,
service_name: &'static str,
tracing_config: TracingConfig,
worker_threads: WorkerThreads,
) -> Result<(), Report<GraphError>> {
static THREAD_POOL: Once = Once::new();
THREAD_POOL.call_once(|| {
rayon::ThreadPoolBuilder::new()
.num_threads(worker_threads.resolve().get())
.thread_name(|index| format!("rayon-{index}"))
.build_global()
.expect("rayon pool should be initialized exactly once");
Comment thread
indietyp marked this conversation as resolved.
});

tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
Expand All @@ -159,24 +248,49 @@ fn block_on(
}

impl Subcommand {
pub(crate) fn execute(self, tracing_config: TracingConfig) -> Result<(), Report<GraphError>> {
pub(crate) fn execute(
self,
tracing_config: TracingConfig,
worker_threads: WorkerThreads,
) -> Result<(), Report<GraphError>> {
match self {
Self::Server(args) => block_on(server(*args), "Graph API", tracing_config),
Self::AdminServer(args) => {
block_on(admin_server(*args), "Graph Admin API", tracing_config)
}
Self::Migrate(args) => block_on(migrate(*args), "Graph Migrations", tracing_config),
Self::TypeFetcher(args) => {
block_on(type_fetcher(*args), "Type Fetcher", tracing_config)
Self::Server(args) => {
block_on(server(*args), "Graph API", tracing_config, worker_threads)
}
Self::AdminServer(args) => block_on(
admin_server(*args),
"Graph Admin API",
tracing_config,
worker_threads,
),
Self::Migrate(args) => block_on(
migrate(*args),
"Graph Migrations",
tracing_config,
worker_threads,
),
Self::TypeFetcher(args) => block_on(
type_fetcher(*args),
"Type Fetcher",
tracing_config,
worker_threads,
),
Self::Completions(ref args) => {
completions(args);
Ok(())
}
Self::Snapshot(args) => block_on(snapshot(*args), "Graph Snapshot", tracing_config),
Self::ReindexCache(args) => {
block_on(reindex_cache(*args), "Graph Indexer", tracing_config)
}
Self::Snapshot(args) => block_on(
snapshot(*args),
"Graph Snapshot",
tracing_config,
worker_threads,
),
Self::ReindexCache(args) => block_on(
reindex_cache(*args),
"Graph Indexer",
tracing_config,
worker_threads,
),
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions apps/hash-graph/src/subcommand/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use harpc_server::Server;
use hash_codec::bytes::JsonLinesEncoder;
use hash_graph_api::{
rest::{
ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, hashql::CompilerContext,
rest_api_router,
ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, entity::ClusteringContext,
hashql::CompilerContext, rest_api_router,
},
rpc::Dependencies,
};
Expand Down Expand Up @@ -239,6 +239,13 @@ pub struct ServerConfig {
#[clap(flatten)]
pub compiler: CompilerConfig,

/// Maximum number of entity-clustering requests processed at the same time.
///
/// Excess requests wait until a slot frees up. If not set, the number of concurrent
/// clustering requests is unbounded.
#[clap(long, env = "HASH_GRAPH_CLUSTERING_CONCURRENCY_LIMIT")]
pub clustering_concurrency_limit: Option<NonZero<usize>>,

/// Outputs the queries made to the graph to the specified file.
#[clap(long)]
pub log_queries: Option<PathBuf>,
Expand Down Expand Up @@ -457,6 +464,7 @@ where
query_logger,
api_config: config.api_config,
compiler,
clustering: Arc::new(ClusteringContext::new(config.clustering_concurrency_limit)),
});
start_rest_server(router, config.http_address, lifecycle);

Expand Down
Loading
Loading