From 21f2fdc9ade5549f0ce40f991e66a580e6ecc115 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:40:30 +0200 Subject: [PATCH 01/10] feat: remove read module chore: add new dependency chore: format feat: error module feat: introduce hashql_eval interner chore: checkpoint feat: checkpoint feat: checkpoint chore: remove old value module feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint chore: checkpoint feat: move entity query into its own modul fix: query request feat: checkpoint (it compiles!) feat: checkpoint feat: checkpoint feat: checkpoint fix: issue around cached thunking feat: covariance for opaque inners fix: cfgattr serde chore: remove graph module fix: merge fuckup --- Cargo.lock | 1 + apps/hash-graph/src/subcommand/server.rs | 108 +++++++- libs/@local/graph/api/Cargo.toml | 1 + libs/@local/graph/api/src/lib.rs | 1 + .../graph/api/src/rest/hashql/compile.rs | 190 ++++++++++++++ .../@local/graph/api/src/rest/hashql/error.rs | 148 +++++++++++ libs/@local/graph/api/src/rest/hashql/mod.rs | 236 ++++++++++++++++++ .../@local/graph/api/src/rest/hashql/value.rs | 124 +++++++++ libs/@local/graph/api/src/rest/mod.rs | 37 ++- .../postgres-store/src/store/postgres/mod.rs | 11 +- tests/graph/http/test.sh | 2 + tests/graph/http/tests/hashql.http | 188 ++++++++++++++ 12 files changed, 1039 insertions(+), 8 deletions(-) create mode 100644 libs/@local/graph/api/src/rest/hashql/compile.rs create mode 100644 libs/@local/graph/api/src/rest/hashql/error.rs create mode 100644 libs/@local/graph/api/src/rest/hashql/mod.rs create mode 100644 libs/@local/graph/api/src/rest/hashql/value.rs create mode 100644 tests/graph/http/tests/hashql.http diff --git a/Cargo.lock b/Cargo.lock index d4535eeefaf..6209888d114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,6 +3561,7 @@ dependencies = [ "hashql-diagnostics", "hashql-eval", "hashql-hir", + "hashql-mir", "hashql-syntax-jexpr", "http 1.4.0", "hyper", diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 8c2dbeb27ab..c3133969198 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -2,7 +2,7 @@ use alloc::sync::Arc; use core::{ fmt, net::{AddrParseError, SocketAddr}, - str::FromStr as _, + str::FromStr, time::Duration, }; use std::path::PathBuf; @@ -14,7 +14,10 @@ use harpc_codec::json::JsonCodec; use harpc_server::Server; use hash_codec::bytes::JsonLinesEncoder; use hash_graph_api::{ - rest::{ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, rest_api_router}, + rest::{ + ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, hashql::CompilerContext, + rest_api_router, + }, rpc::Dependencies, }; use hash_graph_authorization::policies::store::{PolicyStore, PrincipalStore}; @@ -103,6 +106,73 @@ pub struct TemporalConfig { pub address: TemporalAddress, } +/// A pool size that can be either a concrete count or unbounded. +/// +/// Parses positive integers as a bounded size and `-1` as unbounded. +#[derive(Debug, Copy, Clone)] +pub struct PoolSize(Option); + +impl PoolSize { + #[inline] + const fn get(self) -> Option { + self.0 + } +} + +impl fmt::Display for PoolSize { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(size) => write!(fmt, "{size}"), + None => write!(fmt, "-1"), + } + } +} + +impl FromStr for PoolSize { + type Err = ::Err; + + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "negative values produce None, and pool sizes never approach u32::MAX" + )] + fn from_str(s: &str) -> Result { + let value = s.parse::()?; + if value < 0 { + Ok(Self(None)) + } else { + Ok(Self(Some(value as usize))) + } + } +} + +/// Configuration for the HashQL compiler and execution pool. +#[derive(Debug, Clone, Parser)] +pub struct CompilerConfig { + /// Number of pre-allocated heap/scratch instances in the compiler memory pool. + /// + /// Set to -1 for an unbounded pool that grows without limit. + #[clap( + long, + default_value = "16", + env = "HASH_GRAPH_COMPILER_MEMORY_POOL_SIZE", + allow_hyphen_values = true + )] + pub compiler_memory_pool_size: PoolSize, + + /// Number of threads in the compiler execution pool. + /// + /// Each thread runs a `LocalSet` for `!Send` query execution. Set to -1 to use the number + /// of available CPU cores. + #[clap( + long, + default_value = "-1", + env = "HASH_GRAPH_COMPILER_EXEC_POOL_SIZE", + allow_hyphen_values = true + )] + pub compiler_exec_pool_size: PoolSize, +} + /// Configuration for the main graph API server. /// /// Groups HTTP address, RPC address, temporal client, store behavior, and @@ -167,6 +237,9 @@ pub struct ServerConfig { #[clap(flatten)] pub api_config: ApiConfig, + #[clap(flatten)] + pub compiler: CompilerConfig, + /// Outputs the queries made to the graph to the specified file. #[clap(long)] pub log_queries: Option, @@ -233,7 +306,12 @@ async fn run_rest_server( async fn create_temporal_client( config: &TemporalConfig, ) -> Result, Report> { - if let Some(host) = &config.address.temporal_host { + if let Some(host) = config + .address + .temporal_host + .as_deref() + .filter(|host| !host.is_empty()) + { TemporalClientConfig::new( Url::from_str(&format!("{host}:{}", config.address.temporal_port)) .change_context(GraphError)?, @@ -314,6 +392,8 @@ where /// Starts the main graph API server (REST + optional RPC). async fn start_server( pool: S, + postgres: PostgresStorePool, + compiler: Arc, config: ServerConfig, query_logger: Option, lifecycle: &ServerLifecycle, @@ -343,10 +423,12 @@ where let router = rest_api_router(RestRouterDependencies { store, - domain_regex: DomainValidator::new(config.allowed_url_domain), + postgres, temporal_client, + domain_regex: DomainValidator::new(config.allowed_url_domain), query_logger, api_config: config.api_config, + compiler, }); start_rest_server(router, config.http_address, lifecycle); @@ -405,6 +487,8 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { let lifecycle = ServerLifecycle::new(); + let postgres = pool.clone(); + if args.embed_admin { start_admin_server(pool.clone(), args.admin, &lifecycle); } @@ -441,7 +525,21 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { None }; - if let Err(error) = start_server(pool, args.config, query_logger, &lifecycle).await { + let compiler = Arc::new(CompilerContext::new( + args.config.compiler.compiler_memory_pool_size.get(), + args.config.compiler.compiler_exec_pool_size.get(), + )); + + if let Err(error) = start_server( + pool, + postgres, + compiler, + args.config, + query_logger, + &lifecycle, + ) + .await + { lifecycle.shutdown_and_wait().await; return Err(error); } diff --git a/libs/@local/graph/api/Cargo.toml b/libs/@local/graph/api/Cargo.toml index 71f179cdc65..b9074104a3a 100644 --- a/libs/@local/graph/api/Cargo.toml +++ b/libs/@local/graph/api/Cargo.toml @@ -46,6 +46,7 @@ hashql-ast = { workspace = true } hashql-diagnostics = { workspace = true, features = ["serde", "render"] } hashql-eval = { workspace = true } hashql-hir = { workspace = true } +hashql-mir = { workspace = true } hashql-syntax-jexpr = { workspace = true } type-system = { workspace = true, features = ["utoipa"] } diff --git a/libs/@local/graph/api/src/lib.rs b/libs/@local/graph/api/src/lib.rs index 7b4903ec731..6eced828a20 100644 --- a/libs/@local/graph/api/src/lib.rs +++ b/libs/@local/graph/api/src/lib.rs @@ -9,6 +9,7 @@ // Library Features error_generic_member_access, + allocator_api )] extern crate alloc; diff --git a/libs/@local/graph/api/src/rest/hashql/compile.rs b/libs/@local/graph/api/src/rest/hashql/compile.rs new file mode 100644 index 00000000000..0e12fbd2a47 --- /dev/null +++ b/libs/@local/graph/api/src/rest/hashql/compile.rs @@ -0,0 +1,190 @@ +use hashql_ast::error::AstDiagnosticCategory; +use hashql_core::{ + heap::{Heap, ResetAllocator as _, Scratch}, + module::ModuleRegistry, + span::{SpanId, SpanTable}, + r#type::environment::Environment, +}; +use hashql_diagnostics::{DiagnosticIssues, IntoStatus as _, Status, StatusExt as _, Success}; +use hashql_eval::{ + context::{CodeExecutionContext, CodeGenerationContext}, + postgres::{PostgresCompiler, PreparedQueries}, +}; +use hashql_hir::error::HirDiagnosticCategory; +use hashql_mir::{ + body::Body, + def::{DefId, DefIdVec}, + error::MirDiagnosticCategory, + pass::{LowerConfig, execution::ExecutionAnalysisResidual}, +}; +use hashql_syntax_jexpr::span::Span; + +use super::error::HashQlDiagnosticCategory; + +pub(crate) struct CodeCompilationArtifact<'heap> { + pub assignment: DefIdVec>, &'heap Heap>, + + pub interpreter: DefIdVec, &'heap Heap>, + pub postgres: PreparedQueries<'heap, &'heap Heap>, +} + +pub(crate) struct Compilation<'heap> { + pub heap: &'heap Heap, + + pub root_span: SpanId, + + pub interner: hashql_eval::intern::Interner<'heap>, + pub env: Environment<'heap>, + + pub entrypoint: DefId, + pub artifact: CodeCompilationArtifact<'heap>, +} + +impl<'heap> Compilation<'heap> { + #[expect(clippy::too_many_lines, reason = "orchestration of sequential tasks")] + pub(crate) fn compile( + heap: &'heap Heap, + scratch: &mut Scratch, + spans: &mut SpanTable, + query: &[u8], + ) -> Status { + // Parse the query + let mut parser = hashql_syntax_jexpr::Parser::new(heap, spans); + let Success { + value: mut ast, + advisories, + } = parser + .parse_expr(query) + .into_status() + .map_category(HashQlDiagnosticCategory::JExpr)?; + + let root_span = ast.span; + + let mut env = Environment::new(heap); + let modules = ModuleRegistry::new(&env); + + // Lower the AST + let Success { + value: types, + advisories, + } = hashql_ast::lowering::lower(heap.intern_symbol("main"), &mut ast, &env, &modules) + .map_category(|category| { + HashQlDiagnosticCategory::Ast(AstDiagnosticCategory::Lowering(category)) + }) + .with_diagnostics(advisories)?; + + let interner = hashql_hir::intern::Interner::new(heap); + let mut hir_context = hashql_hir::context::HirContext::new(&interner, &modules); + + // Reify the HIR from the AST + let Success { + value: hir, + advisories, + } = hashql_hir::node::NodeData::from_ast(ast, &mut hir_context, &types) + .map_category(|category| { + HashQlDiagnosticCategory::Hir(HirDiagnosticCategory::Reification(category)) + }) + .with_diagnostics(advisories)?; + + // Lower the HIR + let Success { + value: hir, + advisories, + } = hashql_hir::lower::lower(hir, &types, &mut env, &mut hir_context) + .map_category(|category| { + HashQlDiagnosticCategory::Hir(HirDiagnosticCategory::Lowering(category)) + }) + .with_diagnostics(advisories)?; + + let interner = hashql_mir::intern::Interner::new(heap); + let mut bodies = DefIdVec::new_in(heap); + let mut mir_context = hashql_mir::context::MirContext { + heap, + env: &env, + interner: &interner, + diagnostics: DiagnosticIssues::new(), + }; + let mut reify_context = hashql_mir::reify::ReifyContext { + bodies: &mut bodies, + mir: &mut mir_context, + hir: &hir_context, + scratch: &*scratch, + }; + + // Reify the MIR from the HIR + let Success { + value: entrypoint, + advisories, + } = hashql_mir::reify::from_hir(hir, &mut reify_context) + .map_category(|category| { + HashQlDiagnosticCategory::Mir(MirDiagnosticCategory::Reify(category)) + }) + .with_diagnostics(advisories)?; + + // Lower the MIR + let Success { + value: (), + advisories, + } = hashql_mir::pass::lower( + &mut mir_context, + scratch, + &mut bodies, + &LowerConfig::default(), + ) + .map_category(HashQlDiagnosticCategory::Mir) + .with_diagnostics(advisories)?; + + // Plan the execution + let Success { + value: execution, + advisories, + } = hashql_mir::pass::place(&mut mir_context, scratch, &mut bodies) + .map_category(HashQlDiagnosticCategory::Mir) + .with_diagnostics(advisories)?; + + // Build the postgres artifacts + let interner = interner.into(); + let mut context = CodeGenerationContext::new_in( + &env, + &interner, + &bodies, + &execution, + heap, + &mut *scratch, + ); + + let mut postgres = PostgresCompiler::new_in(&mut context, &mut *scratch); + let queries = postgres.compile(); + scratch.reset(); + + context + .diagnostics + .into_status(()) + .map_category(HashQlDiagnosticCategory::Eval) + .with_diagnostics(advisories)?; + + Status::success(Self { + heap, + + root_span, + env, + interner, + entrypoint, + artifact: CodeCompilationArtifact { + assignment: execution, + interpreter: bodies, + postgres: queries, + }, + }) + } + + pub(crate) fn context(&self) -> CodeExecutionContext<'_, 'heap, &'heap Heap> { + CodeExecutionContext { + env: &self.env, + interner: &self.interner, + bodies: &self.artifact.interpreter, + execution: &self.artifact.assignment, + alloc: self.heap, + } + } +} diff --git a/libs/@local/graph/api/src/rest/hashql/error.rs b/libs/@local/graph/api/src/rest/hashql/error.rs new file mode 100644 index 00000000000..0fb9d7ed609 --- /dev/null +++ b/libs/@local/graph/api/src/rest/hashql/error.rs @@ -0,0 +1,148 @@ +use alloc::borrow::Cow; +use core::ops::Range; + +use axum::response::{Html, IntoResponse as _}; +use hashql_ast::error::AstDiagnosticCategory; +use hashql_core::span::{SpanId, SpanTable}; +use hashql_diagnostics::{ + DiagnosticCategory, Failure, Sources, Status, Success, + category::{TerminalDiagnosticCategory, canonical_category_id}, + diagnostic::render::{Format, RenderOptions}, + severity::Critical, +}; +use hashql_eval::error::EvalDiagnosticCategory; +use hashql_hir::error::HirDiagnosticCategory; +use hashql_mir::error::MirDiagnosticCategory; +use hashql_syntax_jexpr::{error::JExprDiagnosticCategory, span::Span}; +use http::StatusCode; + +use super::{ + CompilationOutputOptions, + value::{JsonValueSerialize, OwnedValue}, +}; +use crate::rest::{json::Json, status::BoxedResponse}; + +const INFRASTRUCTURE_CATEGORY: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "infrastructure", + name: "Infrastructure", +}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) enum HashQlDiagnosticCategory { + JExpr(JExprDiagnosticCategory), + Ast(AstDiagnosticCategory), + Hir(HirDiagnosticCategory), + Mir(MirDiagnosticCategory), + Eval(EvalDiagnosticCategory), + Infrastructure, +} + +impl serde::Serialize for HashQlDiagnosticCategory { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&canonical_category_id(self)) + } +} + +impl DiagnosticCategory for HashQlDiagnosticCategory { + fn id(&self) -> Cow<'_, str> { + Cow::Borrowed("hashql") + } + + fn name(&self) -> Cow<'_, str> { + Cow::Borrowed("HashQL") + } + + fn subcategory(&self) -> Option<&dyn DiagnosticCategory> { + match self { + Self::JExpr(jexpr) => Some(jexpr), + Self::Ast(ast) => Some(ast), + Self::Hir(hir) => Some(hir), + Self::Mir(mir) => Some(mir), + Self::Eval(eval) => Some(eval), + Self::Infrastructure => Some(&INFRASTRUCTURE_CATEGORY), + } + } +} + +#[derive(Debug, serde::Serialize)] +struct PointerSpan { + pub range: Range, + pub pointer: Option, +} + +impl PointerSpan { + fn resolve(id: SpanId, spans: &SpanTable) -> Option { + let absolute = spans.absolute(id)?; + + let mut pointer = None; + + for ancestor in spans.ancestors(id) { + let Some(span) = spans.get(ancestor) else { + continue; + }; + + if let Some(span_pointer) = &span.pointer { + pointer = Some(span_pointer.as_str().to_owned()); + break; + } + } + + Some(Self { + range: absolute.range().into(), + pointer, + }) + } +} + +pub(crate) fn status_to_response( + status: Status, + sources: &Sources<'_>, + mut spans: &SpanTable, + options: &CompilationOutputOptions, +) -> BoxedResponse { + match status { + Ok(Success { value, advisories }) => { + let advisories = advisories.map_spans(|span| PointerSpan::resolve(span, spans)); + + if options.json_compat { + Json(Success { + value: JsonValueSerialize(&value), + advisories, + }) + .into_response() + .into() + } else { + Json(Success { value, advisories }).into_response().into() + } + } + Err(Failure { primary, secondary }) => { + let severity = primary.severity; + let status_code = if severity == Critical::ERROR { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + + let mut response = if options.interactive { + let mut diagnostics = secondary.generalize(); + diagnostics.insert_front(primary.generalize()); + + let output = + diagnostics.render(RenderOptions::new(Format::Html, sources), &mut spans); + Html(output).into_response() + } else { + Json(Failure { + primary: Box::new(primary.map_spans(|span| PointerSpan::resolve(span, spans))), + secondary: secondary.map_spans(|span| PointerSpan::resolve(span, spans)), + }) + .into_response() + }; + + *response.status_mut() = status_code; + response.into() + } + } +} diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs new file mode 100644 index 00000000000..747b7dba904 --- /dev/null +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -0,0 +1,236 @@ +//! HashQL query endpoint. +//! +//! Accepts a HashQL query as raw JSON, compiles it through the full pipeline +//! (parse, type-check, optimize, codegen), executes the generated SQL, and returns +//! the result. Compilation errors are reported as structured diagnostics with +//! source spans. + +mod compile; +mod error; +mod value; + +use alloc::sync::Arc; +use core::num::NonZero; +use std::thread::available_parallelism; + +use axum::{Extension, Router, response::IntoResponse as _, routing::post}; +use hash_graph_postgres_store::store::PostgresStorePool; +use hash_graph_store::pool::StorePool as _; +use hash_temporal_client::TemporalClient; +use hashql_core::{ + heap::{HeapPool, ScratchPool}, + span::{SpanId, SpanTable}, +}; +use hashql_diagnostics::{ + Diagnostic, IntoStatus as _, Label, Message, Source, Sources, Status, StatusExt as _, Success, + severity::Critical, +}; +use hashql_eval::{error::EvalDiagnosticCategory, orchestrator::Orchestrator}; +use hashql_mir::interpret::Inputs; +use hashql_syntax_jexpr::span::Span; +use serde_json::value::RawValue; +use tokio_util::task::LocalPoolHandle; +use utoipa::OpenApi; + +use self::{ + compile::Compilation, + error::{HashQlDiagnosticCategory, status_to_response}, + value::OwnedValue, +}; +use crate::rest::{InteractiveHeader, JsonCompatHeader, json::Json, status::BoxedResponse}; + +/// Shared resources for HashQL query compilation and execution, created once at server startup. +pub struct CompilerContext { + pub scratches: ScratchPool, + pub heaps: HeapPool, + pub pool: LocalPoolHandle, +} + +impl CompilerContext { + /// Creates a new compiler context. + /// + /// `memory_pool_size` bounds the heap and scratch pools; `None` leaves them unbounded. + /// `exec_pool_size` sets the thread count; `None` uses the number of available CPU cores. + pub fn new(memory_pool_size: Option, exec_pool_size: Option) -> Self { + let scratches = memory_pool_size.map_or_else(ScratchPool::new, ScratchPool::bounded); + let heaps = memory_pool_size.map_or_else(HeapPool::new, HeapPool::bounded); + + let thread_count = + exec_pool_size.unwrap_or_else(|| available_parallelism().map_or(4, NonZero::get)); + + let pool = LocalPoolHandle::new(thread_count); + Self { + scratches, + heaps, + pool, + } + } +} + +/// Per-request database context. +struct ExecutionContext { + postgres: PostgresStorePool, + temporal: Option>, +} + +/// Controls the response format for a HashQL query. +pub(crate) struct CompilationOutputOptions { + /// Render errors as HTML with source annotations instead of structured JSON. + pub interactive: bool, + /// Serialize the result as plain JSON values, stripping HashQL-specific type wrappers. + pub json_compat: bool, +} + +/// Compiles and executes a HashQL query, returning the result as a [`Status`]. +#[expect(clippy::future_not_send)] +async fn query_local_impl( + ctx: Arc, + exec: ExecutionContext, + spans: &mut SpanTable, + query: &[u8], +) -> Status { + // Heap and scratch must be created inside this function because `spawn_pinned` requires + // `'static`. Moving them across the spawn boundary isn't possible since they borrow from + // the pool guards. + let mut scratch = ctx.scratches.get(); + let heap = ctx.heaps.get(); + + let inputs = Inputs::new(); // TODO: https://linear.app/hash/issue/BE-41/hashql-expose-input-in-graph-api + + let Success { + value: compilation, + advisories, + } = Compilation::compile(&heap, &mut scratch, spans, query)?; + + let context = compilation.context(); + + let Success { + value: client, + advisories, + } = exec + .postgres + .acquire(exec.temporal) + .await + .map_err(|report| { + let mut diagnostic = + Diagnostic::new(HashQlDiagnosticCategory::Infrastructure, Critical::BUG).primary( + Label::new(compilation.root_span, "failed to acquire postgres client"), + ); + + diagnostic.add_message(Message::note(format!("{report:?}"))); + diagnostic + }) + .into_status() + .with_diagnostics(advisories)?; + + let orchestrator = Orchestrator::new(client, &compilation.artifact.postgres, &context); + orchestrator + .run(&inputs, compilation.entrypoint, []) + .await + .into_status() + .map_category(|category| { + HashQlDiagnosticCategory::Eval(EvalDiagnosticCategory::Orchestrator(category)) + }) + .with_diagnostics(advisories) + .map_value(OwnedValue::from) +} + +#[expect(clippy::future_not_send)] +async fn query_local( + ctx: Arc, + exec: ExecutionContext, + query: Arc, + options: CompilationOutputOptions, +) -> BoxedResponse { + let mut sources = Sources::new(); + let source_id = sources.push(Source::new(query.get())); + + let mut spans = SpanTable::new(source_id); + + let status = query_local_impl(ctx, exec, &mut spans, query.get().as_bytes()).await; + status_to_response(status, &sources, &spans, &options) +} + +/// Spawns a query onto the local thread pool and awaits the response. +async fn run_query( + ctx: Arc, + exec: ExecutionContext, + query: Arc, + options: CompilationOutputOptions, +) -> BoxedResponse { + // The compiler and interpreter hold references into bump-allocated heaps, making their + // futures `!Send`. `spawn_pinned` runs them on a dedicated thread; the returned handle + // is `Send` so the HTTP handler can await it normally. + let pool = ctx.pool.clone(); + let result = pool + .spawn_pinned(|| query_local(ctx, exec, query, options)) + .await; + + result.unwrap_or_else(|_| { + Json(serde_json::json!({"fatal": "internal error: query execution failed"})) + .into_response() + .into() + }) +} + +/// Request body for the `/hashql` endpoint. +#[derive(serde::Deserialize, utoipa::ToSchema)] +pub(crate) struct HashQlRequest { + query: Arc, + /// Input values for the query. Must be an empty list until input support ships. + #[expect( + dead_code, + reason = "inputs will be required once HashQL input support ships" + )] + inputs: Vec<()>, +} + +#[utoipa::path( + post, + path = "/hashql", + request_body = HashQlRequest, + tag = "HashQL", + params( + ("Interactive" = Option, Header, description = "When true, error responses are rendered as HTML instead of JSON"), + ("Json-Compat" = Option, Header, description = "When true, serializes the result as plain JSON values, stripping HashQL-specific type wrappers"), + ), + responses( + (status = 200, content_type = "application/json", description = "Query executed successfully"), + (status = 400, content_type = "application/json", description = "Query compilation or validation error"), + (status = 500, description = "Internal compiler or database error"), + ) +)] +pub(crate) async fn query_hashql( + Extension(compiler): Extension>, + Extension(postgres): Extension>, + Extension(temporal): Extension>>, + InteractiveHeader(interactive): InteractiveHeader, + JsonCompatHeader(json_compat): JsonCompatHeader, + Json(request): Json, +) -> BoxedResponse { + let exec = ExecutionContext { + postgres: (*postgres).clone(), + temporal, + }; + + let options = CompilationOutputOptions { + interactive, + json_compat, + }; + + run_query(compiler, exec, request.query, options).await +} + +#[derive(OpenApi)] +#[openapi( + paths(query_hashql), + components(schemas(HashQlRequest)), + tags((name = "HashQL", description = "HashQL query execution API")) +)] +pub(crate) struct HashQlResource; + +impl HashQlResource { + pub(crate) fn routes() -> Router { + Router::new().route("/hashql", post(query_hashql)) + } +} diff --git a/libs/@local/graph/api/src/rest/hashql/value.rs b/libs/@local/graph/api/src/rest/hashql/value.rs new file mode 100644 index 00000000000..1c6c2c9bfa6 --- /dev/null +++ b/libs/@local/graph/api/src/rest/hashql/value.rs @@ -0,0 +1,124 @@ +use alloc::{collections::BTreeMap, sync::Arc}; +use core::alloc::Allocator; + +use hashql_core::id::Id as _; +use hashql_mir::interpret::value::{Int, Num, Ptr, Value}; +use serde::Serialize as _; + +fn serialize_int(int: &Int, serializer: S) -> Result { + int.as_int().serialize(serializer) +} + +#[expect(clippy::trivially_copy_pass_by_ref)] +fn serialize_num(num: &Num, serializer: S) -> Result { + num.as_f64().serialize(serializer) +} + +#[expect(clippy::trivially_copy_pass_by_ref)] +fn serialize_ptr(ptr: &Ptr, serializer: S) -> Result { + ptr.def().as_u32().serialize(serializer) +} + +// This is only here until https://linear.app/hash/issue/BE-540/hashql-register-based-bytecode-vm +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub(crate) enum OwnedValue { + /// The unit value. + Unit, + /// An integer value (also represents booleans). + Integer(#[serde(serialize_with = "serialize_int")] Int), + /// A floating-point number. + Number(#[serde(serialize_with = "serialize_num")] Num), + /// A string value. + String(Arc), + /// A function pointer. + Pointer(#[serde(serialize_with = "serialize_ptr")] Ptr), + + /// An opaque/newtype wrapper. + Opaque(Arc, Box), + /// A named-field struct. + Struct(Vec<(Arc, Self)>), + /// A positional tuple. + Tuple(Vec), + /// An ordered list. + List(Vec), + /// An ordered dictionary. + Dict(BTreeMap), +} + +impl<'heap, A: Allocator + Clone> From> for OwnedValue { + fn from(value: Value<'heap, A>) -> Self { + match value { + Value::Unit => Self::Unit, + Value::Integer(int) => Self::Integer(int), + Value::Number(num) => Self::Number(num), + Value::String(str) => Self::String(Arc::from(str.as_str())), + Value::Pointer(ptr) => Self::Pointer(ptr), + Value::Opaque(opaque) => Self::Opaque( + Arc::from(opaque.name().as_str()), + Box::new(opaque.into_value().into()), + ), + Value::Struct(r#struct) => { + debug_assert_eq!(r#struct.fields().len(), r#struct.values().len()); + + Self::Struct( + r#struct + .fields() + .iter() + .zip(r#struct.values()) + .map(|(field, value)| { + (Arc::from(field.as_str()), Self::from(value.clone())) + }) + .collect(), + ) + } + Value::Tuple(tuple) => Self::Tuple( + tuple + .values() + .iter() + .map(|value| Self::from(value.clone())) + .collect(), + ), + Value::List(list) => { + Self::List(list.iter().map(|value| Self::from(value.clone())).collect()) + } + Value::Dict(dict) => Self::Dict( + dict.iter() + .map(|(key, value)| (Self::from(key.clone()), Self::from(value.clone()))) + .collect(), + ), + } + } +} + +#[derive(Copy, Clone)] +pub(crate) struct JsonValueSerialize<'value>(pub &'value OwnedValue); + +impl serde::Serialize for JsonValueSerialize<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.0 { + OwnedValue::Unit => serializer.serialize_unit(), + OwnedValue::Integer(int) => serialize_int(int, serializer), + OwnedValue::Number(num) => serialize_num(num, serializer), + OwnedValue::String(str) => serde::Serialize::serialize(str.as_ref(), serializer), + OwnedValue::Pointer(ptr) => serialize_ptr(ptr, serializer), + OwnedValue::Opaque(_, owned_value) => { + serde::Serialize::serialize(&Self(owned_value), serializer) + } + OwnedValue::Struct(items) => { + serializer.collect_map(items.iter().map(|(key, value)| (key, Self(value)))) + } + OwnedValue::Tuple(owned_values) => { + serializer.collect_seq(owned_values.iter().map(Self)) + } + OwnedValue::List(owned_values) => serializer.collect_seq(owned_values.iter().map(Self)), + OwnedValue::Dict(btree_map) => serializer.collect_map( + btree_map + .iter() + .map(|(key, value)| (Self(key), Self(value))), + ), + } + } +} diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 26deb299e9e..7e9ab4d39d9 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -16,6 +16,7 @@ pub mod admin; pub mod http_tracing_layer; pub mod jwt; +pub mod hashql; mod json; mod utoipa_typedef; use alloc::{borrow::Cow, sync::Arc}; @@ -37,7 +38,7 @@ use error_stack::{Report, ResultExt as _}; use futures::{SinkExt as _, channel::mpsc::Sender}; use hash_codec::numeric::Real; use hash_graph_authorization::policies::store::{PolicyStore, PrincipalStore}; -use hash_graph_postgres_store::store::error::VersionedUrlAlreadyExists; +use hash_graph_postgres_store::store::{PostgresStorePool, error::VersionedUrlAlreadyExists}; use hash_graph_store::{ account::AccountStore, data_type::DataTypeStore, @@ -169,6 +170,32 @@ impl FromRequestParts for InteractiveHeader { } } +pub struct JsonCompatHeader(pub bool); + +impl FromRequestParts for JsonCompatHeader { + type Rejection = (StatusCode, Cow<'static, str>); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let Some(value) = parts.headers.get("Json-Compat") else { + return Ok(Self(false)); + }; + + let bytes = value.as_ref(); + if bytes.eq_ignore_ascii_case(b"true") || bytes.eq_ignore_ascii_case(b"1") { + return Ok(Self(true)); + } + + if bytes.eq_ignore_ascii_case(b"false") || bytes.eq_ignore_ascii_case(b"0") { + return Ok(Self(false)); + } + + Err(( + StatusCode::BAD_REQUEST, + Cow::Borrowed("`Json-Compat` header must be either `true` (`1`) or `false` (`0`)"), + )) + } +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct PermissionResponse { pub has_permission: bool, @@ -250,6 +277,7 @@ fn api_documentation() -> Vec { entity::EntityResource::openapi(), permissions::PermissionResource::openapi(), principal::PrincipalResource::openapi(), + hashql::HashQlResource::openapi(), ] } @@ -395,10 +423,12 @@ where S: StorePool + Send + Sync + 'static, { pub store: Arc, + pub postgres: PostgresStorePool, pub temporal_client: Option>, pub domain_regex: DomainValidator, pub query_logger: Option, pub api_config: ApiConfig, + pub compiler: Arc, } /// A [`Router`] that only serves the `OpenAPI` specification (JSON, and necessary subschemas) for @@ -426,6 +456,7 @@ where let merged_routes = api_resources::() .into_iter() .fold(Router::new(), Router::merge) + .merge(hashql::HashQlResource::routes()) .fallback(|| { tracing::error!("404: Not found"); async { StatusCode::NOT_FOUND } @@ -443,9 +474,11 @@ where ) .layer(http_tracing_layer::HttpTracingLayer) .layer(Extension(dependencies.store)) + .layer(Extension(Arc::new(dependencies.postgres))) .layer(Extension(dependencies.temporal_client)) .layer(Extension(dependencies.domain_regex)) - .layer(Extension(dependencies.api_config)); + .layer(Extension(dependencies.api_config)) + .layer(Extension(dependencies.compiler)); if let Some(query_logger) = dependencies.query_logger { router = router.layer(Extension(query_logger)); diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index 5fd8a492611..89d6f84cc77 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -51,7 +51,7 @@ use hash_status::StatusCode; use hash_temporal_client::TemporalClient; use postgres_types::{Json, ToSql}; use time::OffsetDateTime; -use tokio_postgres::{GenericClient as _, error::SqlState}; +use tokio_postgres::{Client, GenericClient as _, error::SqlState}; use tracing::Instrument as _; use type_system::{ Valid, @@ -112,6 +112,15 @@ pub struct PostgresStore { pub settings: Arc, } +impl AsRef for PostgresStore +where + C: AsClient, +{ + fn as_ref(&self) -> &Client { + self.client.as_client() + } +} + impl PostgresStore> { /// Inserts multiple policies into the database. /// diff --git a/tests/graph/http/test.sh b/tests/graph/http/test.sh index f97a1f651e5..d66e0992030 100755 --- a/tests/graph/http/test.sh +++ b/tests/graph/http/test.sh @@ -11,3 +11,5 @@ yarn httpyac send --all tests/ambiguous.http -o none yarn reset-database -o none yarn httpyac send --all tests/link-inheritance.http -o none yarn reset-database -o none +yarn httpyac send --all tests/hashql.http -o none +yarn reset-database -o none diff --git a/tests/graph/http/tests/hashql.http b/tests/graph/http/tests/hashql.http new file mode 100644 index 00000000000..5dfe5041548 --- /dev/null +++ b/tests/graph/http/tests/hashql.http @@ -0,0 +1,188 @@ +# This file either runs with JetBrains' http requests or using httpYac (https://httpyac.github.io). + +### Seed default policies +GET http://127.0.0.1:4000/policies/seed +Content-Type: application/json + +> {% + client.test("status", function() { + client.assert(response.status === 204, "Response status is not 204"); + }); +%} + +### Get system user +GET http://127.0.0.1:4000/actors/machine/identifier/system/h +Content-Type: application/json + +> {% + client.test("status", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.global.set("system_machine_id", response.body); +%} + +### Create account +POST http://127.0.0.1:4000/actors/user +Content-Type: application/json +X-Authenticated-User-Actor-Id: {{system_machine_id}} + +{ + "shortname": "alice", + "registrationComplete": true +} + +> {% + client.test("status", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.global.set("user_id", response.body.userId); +%} + +### HashQL: filter-false returns empty list +POST http://127.0.0.1:4000/hashql +Content-Type: application/json + +{ + "query": ["let", "axes", + ["::graph::temporal::PinnedTransactionTimeTemporalAxes", + {"#struct": { + "pinned": ["::graph::temporal::TransactionTime", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ], + "variable": ["::graph::temporal::DecisionTime", + ["::graph::temporal::Interval", {"#struct": { + "start": ["::graph::temporal::UnboundedTemporalBound"], + "end": ["::graph::temporal::ExclusiveTemporalBound", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ] + }}] + ] + }} + ], + ["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", "axes"], + ["fn", {"#tuple": []}, {"#struct": {"vertex": "_"}}, "_", + {"#literal": false} + ] + ] + ] + ], + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.test("empty list", function() { + client.assert(response.body.value !== undefined, "Expected 'value' field"); + client.assert(response.body.value.List !== undefined, "Expected 'List' in value"); + client.assert(response.body.value.List.length === 0, "Expected empty list for filter-false"); + client.assert(response.body.advisories !== undefined, "Expected 'advisories' field"); + }); +%} + +### HashQL: parse error returns 400 with diagnostics +POST http://127.0.0.1:4000/hashql +Content-Type: application/json + +{ + "query": {"not": "a valid query"}, + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Expected 400 for invalid query"); + }); + client.test("has diagnostic", function() { + client.assert(response.body.primary !== undefined, "Expected primary diagnostic"); + client.assert(response.body.primary.category !== undefined, "Expected diagnostic category"); + }); +%} + +### HashQL: missing inputs field returns 400 +POST http://127.0.0.1:4000/hashql +Content-Type: application/json + +{ + "query": {"#literal": 1} +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Expected 400 for missing inputs field"); + }); + client.test("error message mentions inputs", function() { + client.assert(response.body.message !== undefined, "Expected error message"); + client.assert(response.body.contents[0].Error.metadata.deserializationError.includes("inputs"), + "Expected error to mention missing 'inputs' field"); + }); +%} + +### HashQL: json-compat wraps result in envelope with advisories +POST http://127.0.0.1:4000/hashql +Content-Type: application/json +Json-Compat: true + +{ + "query": ["let", "axes", + ["::graph::temporal::PinnedTransactionTimeTemporalAxes", + {"#struct": { + "pinned": ["::graph::temporal::TransactionTime", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ], + "variable": ["::graph::temporal::DecisionTime", + ["::graph::temporal::Interval", {"#struct": { + "start": ["::graph::temporal::UnboundedTemporalBound"], + "end": ["::graph::temporal::ExclusiveTemporalBound", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ] + }}] + ] + }} + ], + ["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", "axes"], + ["fn", {"#tuple": []}, {"#struct": {"vertex": "_"}}, "_", + {"#literal": false} + ] + ] + ] + ], + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.test("envelope shape", function() { + client.assert(response.body.value !== undefined, "Expected 'value' field in envelope"); + client.assert(response.body.advisories !== undefined, "Expected 'advisories' field in envelope"); + client.assert(Array.isArray(response.body.value), "Expected value to be an array"); + client.assert(response.body.value.length === 0, "Expected empty list for filter-false"); + }); +%} + +### HashQL: interactive header returns HTML on error +POST http://127.0.0.1:4000/hashql +Content-Type: application/json +Interactive: true + +{ + "query": {"not": "a valid query"}, + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Expected 400 for invalid query"); + }); + client.test("html response", function() { + var contentType = response.headers.valueOf("content-type"); + client.assert(contentType.includes("text/html"), "Expected HTML content type, got: " + contentType); + }); +%} From 814b01b929a3a2456debf16545ce1cb69b8bb4f7 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:05:37 +0200 Subject: [PATCH 02/10] chore: regen schema --- libs/@local/graph/api/openapi/openapi.json | 75 ++++++++++++++++++++ libs/@local/graph/api/src/rest/hashql/mod.rs | 1 + 2 files changed, 76 insertions(+) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 7893ed31257..85015cfa552 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -2463,6 +2463,58 @@ } } }, + "/hashql": { + "post": { + "tags": [ + "Graph", + "HashQL" + ], + "operationId": "query_hashql", + "parameters": [ + { + "name": "Interactive", + "in": "header", + "description": "When true, error responses are rendered as HTML instead of JSON", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "Json-Compat", + "in": "header", + "description": "When true, serializes the result as plain JSON values, stripping HashQL-specific type wrappers", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HashQlRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Query executed successfully" + }, + "400": { + "description": "Query compilation or validation error" + }, + "500": { + "description": "Internal compiler or database error" + } + } + } + }, "/policies": { "post": { "tags": [ @@ -5492,6 +5544,25 @@ }, "additionalProperties": false }, + "HashQlRequest": { + "type": "object", + "description": "Request body for the `/hashql` endpoint.", + "required": [ + "query", + "inputs" + ], + "properties": { + "inputs": { + "type": "array", + "items": { + "default": null, + "nullable": true + }, + "description": "Input values for the query. Must be an empty list until input support ships." + }, + "query": {} + } + }, "IncludeEntityTypeOption": { "type": "string", "enum": [ @@ -9564,6 +9635,10 @@ { "name": "Principal", "description": "Principal management API" + }, + { + "name": "HashQL", + "description": "HashQL query execution API" } ] } diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 747b7dba904..4724067eb8c 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -176,6 +176,7 @@ async fn run_query( /// Request body for the `/hashql` endpoint. #[derive(serde::Deserialize, utoipa::ToSchema)] pub(crate) struct HashQlRequest { + #[schema(value_type = serde_json::Value)] query: Arc, /// Input values for the query. Must be an empty list until input support ships. #[expect( From 49e6250e04d8fe4c1e708f8052218925aff9d5c7 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:05:15 +0200 Subject: [PATCH 03/10] feat: add 0 as a possibility instead of -1 --- apps/hash-graph/src/subcommand/server.rs | 37 +++++++++----------- libs/@local/graph/api/src/rest/hashql/mod.rs | 9 ++--- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index c3133969198..1efd4b9f772 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -2,6 +2,7 @@ use alloc::sync::Arc; use core::{ fmt, net::{AddrParseError, SocketAddr}, + num::NonZero, str::FromStr, time::Duration, }; @@ -108,41 +109,37 @@ pub struct TemporalConfig { /// A pool size that can be either a concrete count or unbounded. /// -/// Parses positive integers as a bounded size and `-1` as unbounded. +/// Parses positive integers as a bounded size and `0` as unbounded. #[derive(Debug, Copy, Clone)] -pub struct PoolSize(Option); +pub struct PoolSize(Option>); impl PoolSize { #[inline] - const fn get(self) -> Option { + const fn get(self) -> Option> { self.0 } + + #[inline] + fn as_usize(self) -> Option { + self.0.map(NonZero::get) + } } impl fmt::Display for PoolSize { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match self.0 { Some(size) => write!(fmt, "{size}"), - None => write!(fmt, "-1"), + None => write!(fmt, "0"), } } } impl FromStr for PoolSize { - type Err = ::Err; + type Err = ::Err; - #[expect( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "negative values produce None, and pool sizes never approach u32::MAX" - )] fn from_str(s: &str) -> Result { - let value = s.parse::()?; - if value < 0 { - Ok(Self(None)) - } else { - Ok(Self(Some(value as usize))) - } + let value = s.parse::()?; + Ok(Self(NonZero::new(value))) } } @@ -151,7 +148,7 @@ impl FromStr for PoolSize { pub struct CompilerConfig { /// Number of pre-allocated heap/scratch instances in the compiler memory pool. /// - /// Set to -1 for an unbounded pool that grows without limit. + /// Set to 0 for an unbounded pool that grows without limit. #[clap( long, default_value = "16", @@ -162,11 +159,11 @@ pub struct CompilerConfig { /// Number of threads in the compiler execution pool. /// - /// Each thread runs a `LocalSet` for `!Send` query execution. Set to -1 to use the number + /// Each thread runs a `LocalSet` for `!Send` query execution. Set to 0 to use the number /// of available CPU cores. #[clap( long, - default_value = "-1", + default_value = "0", env = "HASH_GRAPH_COMPILER_EXEC_POOL_SIZE", allow_hyphen_values = true )] @@ -526,7 +523,7 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { }; let compiler = Arc::new(CompilerContext::new( - args.config.compiler.compiler_memory_pool_size.get(), + args.config.compiler.compiler_memory_pool_size.as_usize(), args.config.compiler.compiler_exec_pool_size.get(), )); diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 4724067eb8c..9017b65ba31 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -51,14 +51,15 @@ impl CompilerContext { /// /// `memory_pool_size` bounds the heap and scratch pools; `None` leaves them unbounded. /// `exec_pool_size` sets the thread count; `None` uses the number of available CPU cores. - pub fn new(memory_pool_size: Option, exec_pool_size: Option) -> Self { + pub fn new(memory_pool_size: Option, exec_pool_size: Option>) -> Self { let scratches = memory_pool_size.map_or_else(ScratchPool::new, ScratchPool::bounded); let heaps = memory_pool_size.map_or_else(HeapPool::new, HeapPool::bounded); - let thread_count = - exec_pool_size.unwrap_or_else(|| available_parallelism().map_or(4, NonZero::get)); + let thread_count = exec_pool_size.unwrap_or_else(|| { + available_parallelism().unwrap_or(const { NonZero::new(4).unwrap() }) + }); - let pool = LocalPoolHandle::new(thread_count); + let pool = LocalPoolHandle::new(thread_count.get()); Self { scratches, heaps, From cf069451dd06dc6be8a942573e6b85aad754b3ad Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:12:41 +0200 Subject: [PATCH 04/10] fix: value serialization --- apps/hash-graph/src/subcommand/server.rs | 2 +- .../graph/api/src/rest/hashql/compile.rs | 3 +- libs/@local/graph/api/src/rest/hashql/mod.rs | 6 +++- .../@local/graph/api/src/rest/hashql/value.rs | 28 +++++++++++++++---- libs/@local/hashql/core/src/symbol/sym.rs | 3 +- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 1efd4b9f772..8ff79ea3edb 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -146,7 +146,7 @@ impl FromStr for PoolSize { /// Configuration for the HashQL compiler and execution pool. #[derive(Debug, Clone, Parser)] pub struct CompilerConfig { - /// Number of pre-allocated heap/scratch instances in the compiler memory pool. + /// Number of retained heap/scratch instances in the compiler memory pool. /// /// Set to 0 for an unbounded pool that grows without limit. #[clap( diff --git a/libs/@local/graph/api/src/rest/hashql/compile.rs b/libs/@local/graph/api/src/rest/hashql/compile.rs index 0e12fbd2a47..a18ed91e4d0 100644 --- a/libs/@local/graph/api/src/rest/hashql/compile.rs +++ b/libs/@local/graph/api/src/rest/hashql/compile.rs @@ -3,6 +3,7 @@ use hashql_core::{ heap::{Heap, ResetAllocator as _, Scratch}, module::ModuleRegistry, span::{SpanId, SpanTable}, + symbol::sym, r#type::environment::Environment, }; use hashql_diagnostics::{DiagnosticIssues, IntoStatus as _, Status, StatusExt as _, Success}; @@ -67,7 +68,7 @@ impl<'heap> Compilation<'heap> { let Success { value: types, advisories, - } = hashql_ast::lowering::lower(heap.intern_symbol("main"), &mut ast, &env, &modules) + } = hashql_ast::lowering::lower(sym::path::main, &mut ast, &env, &modules) .map_category(|category| { HashQlDiagnosticCategory::Ast(AstDiagnosticCategory::Lowering(category)) }) diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 9017b65ba31..04d2ef5ef7a 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -28,6 +28,7 @@ use hashql_diagnostics::{ use hashql_eval::{error::EvalDiagnosticCategory, orchestrator::Orchestrator}; use hashql_mir::interpret::Inputs; use hashql_syntax_jexpr::span::Span; +use http::StatusCode; use serde_json::value::RawValue; use tokio_util::task::LocalPoolHandle; use utoipa::OpenApi; @@ -168,7 +169,10 @@ async fn run_query( .await; result.unwrap_or_else(|_| { - Json(serde_json::json!({"fatal": "internal error: query execution failed"})) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"fatal": "internal error: query execution failed"})), + ) .into_response() .into() }) diff --git a/libs/@local/graph/api/src/rest/hashql/value.rs b/libs/@local/graph/api/src/rest/hashql/value.rs index 1c6c2c9bfa6..9e06baa1889 100644 --- a/libs/@local/graph/api/src/rest/hashql/value.rs +++ b/libs/@local/graph/api/src/rest/hashql/value.rs @@ -19,6 +19,13 @@ fn serialize_ptr(ptr: &Ptr, serializer: S) -> Result( + dict: &BTreeMap, + serializer: S, +) -> Result { + serializer.collect_seq(dict) +} + // This is only here until https://linear.app/hash/issue/BE-540/hashql-register-based-bytecode-vm #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] pub(crate) enum OwnedValue { @@ -42,7 +49,7 @@ pub(crate) enum OwnedValue { /// An ordered list. List(Vec), /// An ordered dictionary. - Dict(BTreeMap), + Dict(#[serde(serialize_with = "serialize_dict")] BTreeMap), } impl<'heap, A: Allocator + Clone> From> for OwnedValue { @@ -114,11 +121,22 @@ impl serde::Serialize for JsonValueSerialize<'_> { serializer.collect_seq(owned_values.iter().map(Self)) } OwnedValue::List(owned_values) => serializer.collect_seq(owned_values.iter().map(Self)), - OwnedValue::Dict(btree_map) => serializer.collect_map( - btree_map + OwnedValue::Dict(btree_map) => { + let iter = btree_map .iter() - .map(|(key, value)| (Self(key), Self(value))), - ), + .map(|(key, value)| (Self(key), Self(value))); + + // If all the keys are strings we can collect a map, otherwise we must fallback + // to collecting as a sequence + if btree_map + .keys() + .all(|key| matches!(key, OwnedValue::String(_))) + { + serializer.collect_map(iter) + } else { + serializer.collect_seq(iter) + } + } } } } diff --git a/libs/@local/hashql/core/src/symbol/sym.rs b/libs/@local/hashql/core/src/symbol/sym.rs index d4809e9ed2b..1379e05f3a2 100644 --- a/libs/@local/hashql/core/src/symbol/sym.rs +++ b/libs/@local/hashql/core/src/symbol/sym.rs @@ -243,10 +243,11 @@ hashql_macros::define_symbols! { Interval: "::graph::temporal::Interval", LeftClosedTemporalInterval: "::graph::temporal::LeftClosedTemporalInterval", LinkData: "::graph::types::knowledge::entity::LinkData", + main: "::main", None: "::core::option::None", OntologyTypeVersion: "::graph::ontology::OntologyTypeVersion", OpenTemporalBound: "::graph::temporal::OpenTemporalBound", - option: "::core::option::Option", + Option: "::core::option::Option", PinnedDecisionTimeTemporalAxes: "::graph::temporal::PinnedDecisionTimeTemporalAxes", PinnedTransactionTimeTemporalAxes: "::graph::temporal::PinnedTransactionTimeTemporalAxes", PropertyObjectMetadata: "::graph::types::knowledge::entity::PropertyObjectMetadata", From fe3eb9f874aea0b3d35a30e4238dfe24664db728 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:26:16 +0200 Subject: [PATCH 05/10] chore: regen turborepo --- libs/@local/graph/api/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/libs/@local/graph/api/package.json b/libs/@local/graph/api/package.json index aada61f4d33..acafac48ec2 100644 --- a/libs/@local/graph/api/package.json +++ b/libs/@local/graph/api/package.json @@ -36,6 +36,7 @@ "@rust/hashql-diagnostics": "workspace:*", "@rust/hashql-eval": "workspace:*", "@rust/hashql-hir": "workspace:*", + "@rust/hashql-mir": "workspace:*", "@rust/hashql-syntax-jexpr": "workspace:*" } } diff --git a/yarn.lock b/yarn.lock index 54a5e1b8fa0..6a370c6ae5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13057,6 +13057,7 @@ __metadata: "@rust/hashql-diagnostics": "workspace:*" "@rust/hashql-eval": "workspace:*" "@rust/hashql-hir": "workspace:*" + "@rust/hashql-mir": "workspace:*" "@rust/hashql-syntax-jexpr": "workspace:*" languageName: unknown linkType: soft From 094b36e43fd77c1c598400f9ee4762d0fffecf05 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:20:04 +0200 Subject: [PATCH 06/10] chore: remove allow-hyphen-value --- apps/hash-graph/src/subcommand/server.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 8ff79ea3edb..196f94d8a99 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -152,8 +152,7 @@ pub struct CompilerConfig { #[clap( long, default_value = "16", - env = "HASH_GRAPH_COMPILER_MEMORY_POOL_SIZE", - allow_hyphen_values = true + env = "HASH_GRAPH_COMPILER_MEMORY_POOL_SIZE" )] pub compiler_memory_pool_size: PoolSize, @@ -161,12 +160,7 @@ pub struct CompilerConfig { /// /// Each thread runs a `LocalSet` for `!Send` query execution. Set to 0 to use the number /// of available CPU cores. - #[clap( - long, - default_value = "0", - env = "HASH_GRAPH_COMPILER_EXEC_POOL_SIZE", - allow_hyphen_values = true - )] + #[clap(long, default_value = "0", env = "HASH_GRAPH_COMPILER_EXEC_POOL_SIZE")] pub compiler_exec_pool_size: PoolSize, } From ab0eaaa142baba3827c6b4750635be1891d72357 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:28:45 +0200 Subject: [PATCH 07/10] chore: add tracing error logs --- libs/@local/graph/api/src/rest/hashql/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 04d2ef5ef7a..491f39ecc7f 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -168,7 +168,9 @@ async fn run_query( .spawn_pinned(|| query_local(ctx, exec, query, options)) .await; - result.unwrap_or_else(|_| { + result.unwrap_or_else(|error| { + tracing::error!(?error, "panicked by trying to execute query"); + ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"fatal": "internal error: query execution failed"})), From 8ff1e72406ff169dbea236864cb763e1acdef22e Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:35:22 +0200 Subject: [PATCH 08/10] fix: clippy --- libs/@local/graph/api/src/rest/mod.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 7e9ab4d39d9..3ecfa5d27de 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -175,24 +175,27 @@ pub struct JsonCompatHeader(pub bool); impl FromRequestParts for JsonCompatHeader { type Rejection = (StatusCode, Cow<'static, str>); - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + fn from_request_parts( + parts: &mut Parts, + _state: &S, + ) -> impl Future> + Send { let Some(value) = parts.headers.get("Json-Compat") else { - return Ok(Self(false)); + return core::future::ready(Ok(Self(false))); }; let bytes = value.as_ref(); if bytes.eq_ignore_ascii_case(b"true") || bytes.eq_ignore_ascii_case(b"1") { - return Ok(Self(true)); + return core::future::ready(Ok(Self(true))); } if bytes.eq_ignore_ascii_case(b"false") || bytes.eq_ignore_ascii_case(b"0") { - return Ok(Self(false)); + return core::future::ready(Ok(Self(false))); } - Err(( + core::future::ready(Err(( StatusCode::BAD_REQUEST, Cow::Borrowed("`Json-Compat` header must be either `true` (`1`) or `false` (`0`)"), - )) + ))) } } From 87cfd670660d2961322d21c19a0ce09601633c91 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:17:10 +0200 Subject: [PATCH 09/10] fix: suggestions from code review --- libs/@local/graph/api/src/rest/hashql/mod.rs | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 491f39ecc7f..a8ed5e12ed4 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -119,7 +119,12 @@ async fn query_local_impl( Label::new(compilation.root_span, "failed to acquire postgres client"), ); - diagnostic.add_message(Message::note(format!("{report:?}"))); + if cfg!(debug_assertions) { + diagnostic.add_message(Message::note(format!("{report:?}"))); + } else { + tracing::error!(?report, "failed to acquire postgres client"); + } + diagnostic }) .into_status() @@ -169,7 +174,7 @@ async fn run_query( .await; result.unwrap_or_else(|error| { - tracing::error!(?error, "panicked by trying to execute query"); + tracing::error!(?error, "panicked while executing query"); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -180,6 +185,20 @@ async fn run_query( }) } +fn deserialize_empty_inputs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let inputs = serde::Deserialize::<'de>::deserialize(deserializer)?; + if inputs.is_empty() { + return Ok(inputs); + } + + Err(serde::de::Error::custom( + "`inputs` must be an empty array until input support ships", + )) +} + /// Request body for the `/hashql` endpoint. #[derive(serde::Deserialize, utoipa::ToSchema)] pub(crate) struct HashQlRequest { @@ -190,6 +209,7 @@ pub(crate) struct HashQlRequest { dead_code, reason = "inputs will be required once HashQL input support ships" )] + #[serde(deserialize_with = "deserialize_empty_inputs")] inputs: Vec<()>, } From 211e26ce538b80af30903cf9a832ba6068f423fc Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:18:10 +0200 Subject: [PATCH 10/10] fix: type annotation --- libs/@local/graph/api/src/rest/hashql/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index a8ed5e12ed4..62912a8e832 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -189,7 +189,7 @@ fn deserialize_empty_inputs<'de, D>(deserializer: D) -> Result, D::Error where D: serde::Deserializer<'de>, { - let inputs = serde::Deserialize::<'de>::deserialize(deserializer)?; + let inputs: Vec<()> = serde::Deserialize::<'de>::deserialize(deserializer)?; if inputs.is_empty() { return Ok(inputs); }