1use std::{
4 future::Future,
5 net::SocketAddr,
6 path::PathBuf,
7 pin::Pin,
8 sync::{
9 Arc, Mutex,
10 atomic::{AtomicBool, Ordering},
11 },
12 task::{Context, Poll},
13 time::Duration,
14};
15
16use axum::{
17 Router,
18 body::{self, Body},
19 extract::{Extension, Request, State},
20 http::{HeaderMap, HeaderValue, StatusCode, header},
21 middleware::{self, Next},
22 response::{IntoResponse, Response},
23 routing::{get, post},
24};
25use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
26use hyphae_contracts::v1::{
27 CapabilitiesV1, CommitReceiptV1, DefineLexicalIndexRequestV1, DefineVectorSpaceRequestV1,
28 DeleteRequestV1, DeleteVectorsRequestV1, ExactAbstentionReasonV1, ExactAbstentionV1,
29 ExactRetrievalMatchV1, ExactRetrievalOutcomeV1, ExactRetrievalRequestV1,
30 ExactRetrievalResponseV1, GetRequestV1, GetResponseV1, HealthV1, HybridAbstentionV1,
31 HybridBranchAbsenceV1, HybridExplanationV1, HybridRetrievalMatchV1, HybridRetrievalOutcomeV1,
32 HybridRetrievalRequestV1, HybridRetrievalResponseV1, LexicalAbstentionReasonV1,
33 LexicalAbstentionV1, LexicalFieldContributionV1, LexicalRetrievalMatchV1,
34 LexicalRetrievalOutcomeV1, LexicalRetrievalRequestV1, LexicalRetrievalResponseV1,
35 LexicalTermContributionV1, ProofV1, PutRequestV1, PutVectorsRequestV1, QueryRequestV1,
36 QueryResponseV1, RecordV1, RetrievalProofV1, VectorMetricV1, WitnessV1, decode_key_hex,
37 encode_hex,
38};
39use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName, current_version};
40use hyphae_engine::{
41 BoundedEngineQueryError, EngineError, ExactRetrievalProofArtifact,
42 HybridRetrievalProofArtifact, HyphaeEngine, LexicalRetrievalProofArtifact, MaintenanceLimits,
43 ProofError, ProvenResult, ResultProofArtifact, StorageLimits,
44};
45use hyphae_query::{DEFAULT_QUERY_SCAN_BYTES, FieldPath};
46use hyphae_retrieval::{
47 ExactAbstentionReason, ExactRetrievalOutcome, ExactRetrievalRequest, HybridBranchAbsence,
48 HybridOutcome, HybridRequest, LexicalAbstentionReason, LexicalField, LexicalIndexDefinition,
49 LexicalOutcome, LexicalRequest,
50};
51use hyphae_storage::{
52 AppendOutcome, LogError, MaterializedIndexError, SnapshotError, SnapshotReadLimits,
53 StorageError, open_verified_snapshot_with_limits,
54};
55use serde::{Serialize, de::DeserializeOwned};
56use tokio::{
57 io::{AsyncRead, AsyncReadExt as _, ReadBuf},
58 net::TcpListener,
59 sync::{OwnedSemaphorePermit, Semaphore},
60};
61use tokio_util::io::ReaderStream;
62use uuid::Uuid;
63
64use crate::{ApiError, BearerToken, ServerConfig, ServerError, ServerLimits};
65
66const FEATURES: [&str; 14] = [
67 "atomic_batch",
68 "deterministic_query",
69 "durable_vectors",
70 "exact_retrieval",
71 "hybrid_retrieval",
72 "idempotency",
73 "kv",
74 "lexical_retrieval",
75 "offline_result_proof",
76 "offline_retrieval_proof",
77 "provider_free_lexical",
78 "snapshot_witness",
79 "structured_aggregation",
80 "typed_abstention",
81];
82const WITNESS_VERIFICATION_TIMEOUT: Duration = Duration::from_secs(60);
83
84#[derive(Clone, Debug)]
85struct RequestId(String);
86
87struct ServerState {
88 engine: Arc<Mutex<HyphaeEngine>>,
89 data_dir: PathBuf,
90 limits: ServerLimits,
91 bearer_token: Option<BearerToken>,
92 admission: Arc<Semaphore>,
93 ready: AtomicBool,
94}
95
96struct AdmittedFile {
97 file: tokio::fs::File,
98 _permit: OwnedSemaphorePermit,
99}
100
101impl AsyncRead for AdmittedFile {
102 fn poll_read(
103 self: Pin<&mut Self>,
104 context: &mut Context<'_>,
105 buffer: &mut ReadBuf<'_>,
106 ) -> Poll<std::io::Result<()>> {
107 Pin::new(&mut self.get_mut().file).poll_read(context, buffer)
108 }
109}
110
111pub struct HyphaeServer {
113 bind: SocketAddr,
114 state: Arc<ServerState>,
115}
116
117impl HyphaeServer {
118 pub fn open(config: ServerConfig) -> Result<Self, ServerError> {
128 Self::open_with_storage_limits(config, StorageLimits::default())
129 }
130
131 pub fn open_with_storage_limits(
141 config: ServerConfig,
142 storage_limits: StorageLimits,
143 ) -> Result<Self, ServerError> {
144 config.validate()?;
145 let opened = HyphaeEngine::open_with_limits(config.data_dir(), storage_limits)?;
146 let data_dir = opened.engine.data_path().to_path_buf();
147 Ok(Self {
148 bind: config.bind,
149 state: Arc::new(ServerState {
150 engine: Arc::new(Mutex::new(opened.engine)),
151 data_dir,
152 admission: Arc::new(Semaphore::new(config.limits.concurrent_operations)),
153 ready: AtomicBool::new(true),
154 limits: config.limits,
155 bearer_token: config.bearer_token,
156 }),
157 })
158 }
159
160 pub async fn bind(self) -> Result<BoundServer, ServerError> {
166 let listener = TcpListener::bind(self.bind)
167 .await
168 .map_err(|source| ServerError::Bind {
169 address: self.bind,
170 source,
171 })?;
172 let local_addr = listener.local_addr().map_err(|source| ServerError::Bind {
173 address: self.bind,
174 source,
175 })?;
176 Ok(BoundServer {
177 listener,
178 local_addr,
179 router: build_router(self.state),
180 })
181 }
182
183 #[cfg(test)]
184 fn test_router(&self) -> Router {
185 build_router(Arc::clone(&self.state))
186 }
187}
188
189pub struct BoundServer {
191 listener: TcpListener,
192 local_addr: SocketAddr,
193 router: Router,
194}
195
196impl BoundServer {
197 pub fn local_addr(&self) -> SocketAddr {
199 self.local_addr
200 }
201
202 pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), ServerError>
208 where
209 F: Future<Output = ()> + Send + 'static,
210 {
211 axum::serve(self.listener, self.router)
212 .with_graceful_shutdown(shutdown)
213 .await
214 .map_err(ServerError::Serve)
215 }
216}
217
218fn build_router(state: Arc<ServerState>) -> Router {
219 let public = Router::new()
220 .route("/v1/capabilities", get(capabilities))
221 .route("/v1/health/live", get(liveness))
222 .route("/v1/health/ready", get(readiness));
223 let protected = Router::new()
224 .route("/v1/kv/put", post(put_records))
225 .route("/v1/kv/get", post(get_record))
226 .route("/v1/kv/delete", post(delete_records))
227 .route("/v1/query", post(query_records))
228 .route("/v1/vector-spaces/define", post(define_vector_space))
229 .route("/v1/vectors/put", post(put_vectors))
230 .route("/v1/vectors/delete", post(delete_vectors))
231 .route("/v1/retrieve/exact", post(retrieve_exact))
232 .route("/v1/lexical-indexes/define", post(define_lexical_index))
233 .route("/v1/retrieve/lexical", post(retrieve_lexical))
234 .route("/v1/retrieve/hybrid", post(retrieve_hybrid))
235 .route(
236 "/v1/witnesses/{checkpoint_sequence}/{snapshot_digest}",
237 get(download_witness),
238 )
239 .route_layer(middleware::from_fn_with_state(
240 Arc::clone(&state),
241 authenticate,
242 ));
243
244 public
245 .merge(protected)
246 .fallback(route_not_found)
247 .method_not_allowed_fallback(method_not_allowed)
248 .with_state(state)
249 .layer(middleware::from_fn(assign_request_id))
250}
251
252async fn assign_request_id(mut request: Request, next: Next) -> Response {
253 let request_id = RequestId(Uuid::now_v7().to_string());
254 request.extensions_mut().insert(request_id.clone());
255 let mut response = next.run(request).await;
256 if let Ok(value) = HeaderValue::from_str(&request_id.0) {
257 response.headers_mut().insert("x-request-id", value);
258 }
259 response
260}
261
262async fn authenticate(
263 State(state): State<Arc<ServerState>>,
264 request: Request,
265 next: Next,
266) -> Response {
267 let Some(expected) = &state.bearer_token else {
268 return next.run(request).await;
269 };
270 let request_id = request_id(&request);
271 if bearer_candidate(request.headers()).is_some_and(|candidate| expected.verifies(candidate)) {
272 return next.run(request).await;
273 }
274 ApiError::new(
275 StatusCode::UNAUTHORIZED,
276 "unauthorized",
277 "valid bearer authentication is required",
278 request_id,
279 )
280 .into_response()
281}
282
283fn bearer_candidate(headers: &HeaderMap) -> Option<&[u8]> {
284 let mut values = headers.get_all(header::AUTHORIZATION).iter();
285 let value = values.next()?;
286 if values.next().is_some() {
287 return None;
288 }
289 let value = value.as_bytes();
290 let separator = value.iter().position(|byte| *byte == b' ')?;
291 if !value[..separator].eq_ignore_ascii_case(b"bearer") {
292 return None;
293 }
294 let candidate = &value[separator.saturating_add(1)..];
295 (!candidate.is_empty()).then_some(candidate)
296}
297
298async fn liveness(
299 State(state): State<Arc<ServerState>>,
300 Extension(request_id): Extension<RequestId>,
301) -> Result<Response, ApiError> {
302 bounded_json(
303 &HealthV1 {
304 status: "live".to_owned(),
305 },
306 &state,
307 &request_id.0,
308 )
309}
310
311async fn readiness(
312 State(state): State<Arc<ServerState>>,
313 Extension(request_id): Extension<RequestId>,
314) -> Result<Response, ApiError> {
315 if !state.ready.load(Ordering::Acquire) {
316 return Err(ApiError::unavailable(&request_id.0));
317 }
318 bounded_json(
319 &HealthV1 {
320 status: "ready".to_owned(),
321 },
322 &state,
323 &request_id.0,
324 )
325}
326
327async fn capabilities(
328 State(state): State<Arc<ServerState>>,
329 Extension(request_id): Extension<RequestId>,
330) -> Result<Response, ApiError> {
331 let version = current_version();
332 bounded_json(
333 &CapabilitiesV1 {
334 api_version: version.api.to_owned(),
335 disk_format_version: version.disk_format,
336 features: FEATURES.iter().map(ToString::to_string).collect(),
337 limits: state.limits.as_contract(),
338 },
339 &state,
340 &request_id.0,
341 )
342}
343
344async fn put_records(
345 State(state): State<Arc<ServerState>>,
346 Extension(request_id): Extension<RequestId>,
347 request: Request,
348) -> Result<Response, ApiError> {
349 let request: PutRequestV1 = parse_json(request, &state, &request_id.0).await?;
350 validate_batch(request.records.len(), &state, &request_id.0)?;
351 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
352 let records = request
353 .records
354 .iter()
355 .map(RecordV1::to_domain)
356 .collect::<Result<Vec<_>, _>>()
357 .map_err(|_| ApiError::invalid(&request_id.0))?;
358 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
359 capture_write_outcome(engine.put_records(transaction_id, &records))
360 })
361 .await?;
362 if outcome.requires_recovery {
363 state.ready.store(false, Ordering::Release);
364 }
365 bounded_json(&receipt(outcome.append), &state, &request_id.0)
366}
367
368async fn delete_records(
369 State(state): State<Arc<ServerState>>,
370 Extension(request_id): Extension<RequestId>,
371 request: Request,
372) -> Result<Response, ApiError> {
373 let request: DeleteRequestV1 = parse_json(request, &state, &request_id.0).await?;
374 validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
375 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
376 let keys = request
377 .keys_hex
378 .iter()
379 .map(|key| decode_key_hex(key))
380 .collect::<Result<Vec<_>, _>>()
381 .map_err(|_| ApiError::invalid(&request_id.0))?;
382 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
383 let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
384 capture_write_outcome(engine.delete_records(transaction_id, &keys))
385 })
386 .await?;
387 if outcome.requires_recovery {
388 state.ready.store(false, Ordering::Release);
389 }
390 bounded_json(&receipt(outcome.append), &state, &request_id.0)
391}
392
393async fn get_record(
394 State(state): State<Arc<ServerState>>,
395 Extension(request_id): Extension<RequestId>,
396 request: Request,
397) -> Result<Response, ApiError> {
398 let request: GetRequestV1 = parse_json(request, &state, &request_id.0).await?;
399 let key = decode_key_hex(&request.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?;
400 let maintenance =
401 witness_maintenance_limits(state.limits.witness_bytes, WITNESS_VERIFICATION_TIMEOUT);
402 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
403 engine.get_record_with_proof_with_limits(&key, &maintenance)
404 })
405 .await?;
406 let proof = proof_transport(&artifact, &state, &request_id.0)?;
407 let ProvenResult::Get(record) = artifact.proof.result() else {
408 return Err(ApiError::internal(&request_id.0));
409 };
410 let response = GetResponseV1 {
411 found: record.is_some(),
412 record: record.as_ref().map(RecordV1::from_domain),
413 proof,
414 };
415 bounded_json(&response, &state, &request_id.0)
416}
417
418async fn query_records(
419 State(state): State<Arc<ServerState>>,
420 Extension(request_id): Extension<RequestId>,
421 request: Request,
422) -> Result<Response, ApiError> {
423 let request: QueryRequestV1 = parse_json(request, &state, &request_id.0).await?;
424 let timeout = requested_timeout(request.timeout_ms, &state, &request_id.0)?;
425 let query = request
426 .to_domain()
427 .map_err(|_| ApiError::invalid(&request_id.0))?;
428 let mut execution_limits = state.limits.query.clone();
429 execution_limits.timeout = timeout;
430 let maintenance = witness_maintenance_limits(state.limits.witness_bytes, timeout);
431 let artifact = with_bounded_query(Arc::clone(&state), &request_id.0, move |engine| {
432 engine.query_with_proof_with_limits(
433 &query,
434 &execution_limits,
435 DEFAULT_QUERY_SCAN_BYTES,
436 &maintenance,
437 )
438 })
439 .await?;
440 let proof = proof_transport(&artifact, &state, &request_id.0)?;
441 let ProvenResult::Query(result) = artifact.proof.result() else {
442 return Err(ApiError::internal(&request_id.0));
443 };
444 bounded_json(
445 &QueryResponseV1::from_domain(result, proof),
446 &state,
447 &request_id.0,
448 )
449}
450
451async fn define_vector_space(
452 State(state): State<Arc<ServerState>>,
453 Extension(request_id): Extension<RequestId>,
454 request: Request,
455) -> Result<Response, ApiError> {
456 let request: DefineVectorSpaceRequestV1 = parse_json(request, &state, &request_id.0).await?;
457 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
458 if request.vector_space.metric != VectorMetricV1::CosineQ15Nanos {
459 return Err(ApiError::invalid(&request_id.0));
460 }
461 let name = VectorSpaceName::new(request.vector_space.name)
462 .map_err(|_| ApiError::invalid(&request_id.0))?;
463 let definition = VectorSpaceDefinition::cosine(name, request.vector_space.dimension)
464 .map_err(|_| ApiError::invalid(&request_id.0))?;
465 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
466 capture_write_outcome(engine.define_vector_space(transaction_id, definition))
467 })
468 .await?;
469 if outcome.requires_recovery {
470 state.ready.store(false, Ordering::Release);
471 }
472 bounded_json(&receipt(outcome.append), &state, &request_id.0)
473}
474
475async fn put_vectors(
476 State(state): State<Arc<ServerState>>,
477 Extension(request_id): Extension<RequestId>,
478 request: Request,
479) -> Result<Response, ApiError> {
480 let request: PutVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
481 validate_batch(request.vectors.len(), &state, &request_id.0)?;
482 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
483 let space =
484 VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
485 let vectors = request
486 .vectors
487 .into_iter()
488 .map(|vector| {
489 Ok((
490 decode_key_hex(&vector.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?,
491 Q15Vector::new(vector.values).map_err(|_| ApiError::invalid(&request_id.0))?,
492 ))
493 })
494 .collect::<Result<Vec<_>, ApiError>>()?;
495 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
496 capture_write_outcome(engine.put_vectors(transaction_id, &space, &vectors))
497 })
498 .await?;
499 if outcome.requires_recovery {
500 state.ready.store(false, Ordering::Release);
501 }
502 bounded_json(&receipt(outcome.append), &state, &request_id.0)
503}
504
505async fn delete_vectors(
506 State(state): State<Arc<ServerState>>,
507 Extension(request_id): Extension<RequestId>,
508 request: Request,
509) -> Result<Response, ApiError> {
510 let request: DeleteVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
511 validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
512 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
513 let space =
514 VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
515 let keys = request
516 .keys_hex
517 .iter()
518 .map(|key| decode_key_hex(key))
519 .collect::<Result<Vec<_>, _>>()
520 .map_err(|_| ApiError::invalid(&request_id.0))?;
521 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
522 let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
523 capture_write_outcome(engine.delete_vectors(transaction_id, &space, &keys))
524 })
525 .await?;
526 if outcome.requires_recovery {
527 state.ready.store(false, Ordering::Release);
528 }
529 bounded_json(&receipt(outcome.append), &state, &request_id.0)
530}
531
532async fn retrieve_exact(
533 State(state): State<Arc<ServerState>>,
534 Extension(request_id): Extension<RequestId>,
535 request: Request,
536) -> Result<Response, ApiError> {
537 let request: ExactRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
538 let timeout = requested_retrieval_timeout(request.timeout_ms, &state, &request_id.0)?;
539 let request = exact_request(request, &request_id.0)?;
540 let mut limits = state.limits.exact_retrieval.clone();
541 limits.timeout = timeout;
542 let maintenance = witness_maintenance_limits(state.limits.witness_bytes, timeout);
543 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
544 engine.retrieve_exact_with_proof_with_limits(&request, &limits, &maintenance)
545 })
546 .await?;
547 let proof = retrieval_proof_transport(&artifact, &state, &request_id.0)?;
548 let response = ExactRetrievalResponseV1 {
549 outcome: exact_outcome_transport(artifact.proof.outcome()),
550 proof,
551 };
552 bounded_json(&response, &state, &request_id.0)
553}
554
555fn exact_request(
556 request: ExactRetrievalRequestV1,
557 request_id: &str,
558) -> Result<ExactRetrievalRequest, ApiError> {
559 Ok(ExactRetrievalRequest {
560 vector_space: VectorSpaceName::new(request.vector_space)
561 .map_err(|_| ApiError::invalid(request_id))?,
562 query: Q15Vector::new(request.query).map_err(|_| ApiError::invalid(request_id))?,
563 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
564 minimum_score_nanos: request.minimum_score_nanos,
565 minimum_margin_nanos: request.minimum_margin_nanos,
566 })
567}
568
569fn lexical_request(
570 request: LexicalRetrievalRequestV1,
571 request_id: &str,
572) -> Result<LexicalRequest, ApiError> {
573 Ok(LexicalRequest {
574 index: VectorSpaceName::new(request.lexical_index)
575 .map_err(|_| ApiError::invalid(request_id))?,
576 query: request.query,
577 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
578 })
579}
580
581async fn define_lexical_index(
582 State(state): State<Arc<ServerState>>,
583 Extension(request_id): Extension<RequestId>,
584 request: Request,
585) -> Result<Response, ApiError> {
586 let request: DefineLexicalIndexRequestV1 = parse_json(request, &state, &request_id.0).await?;
587 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
588 let name = VectorSpaceName::new(request.lexical_index.name)
589 .map_err(|_| ApiError::invalid(&request_id.0))?;
590 let fields = request
591 .lexical_index
592 .fields
593 .into_iter()
594 .map(|field| {
595 Ok(LexicalField {
596 path: FieldPath::new(field.path),
597 weight_micros: field.weight_micros,
598 })
599 })
600 .collect::<Result<Vec<_>, ApiError>>()?;
601 let definition =
602 LexicalIndexDefinition::new(name, fields).map_err(|_| ApiError::invalid(&request_id.0))?;
603 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
604 capture_write_outcome(engine.define_lexical_index(transaction_id, definition))
605 })
606 .await?;
607 if outcome.requires_recovery {
608 state.ready.store(false, Ordering::Release);
609 }
610 bounded_json(&receipt(outcome.append), &state, &request_id.0)
611}
612
613async fn retrieve_lexical(
614 State(state): State<Arc<ServerState>>,
615 Extension(request_id): Extension<RequestId>,
616 request: Request,
617) -> Result<Response, ApiError> {
618 let request: LexicalRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
619 let timeout = requested_lexical_timeout(request.timeout_ms, &state, &request_id.0)?;
620 let request = lexical_request(request, &request_id.0)?;
621 let mut limits = state.limits.lexical_retrieval.clone();
622 limits.timeout = timeout;
623 let maintenance = witness_maintenance_limits(state.limits.witness_bytes, timeout);
624 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
625 engine.retrieve_lexical_with_proof_with_limits(&request, &limits, &maintenance)
626 })
627 .await?;
628 let proof = lexical_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
629 bounded_json(
630 &LexicalRetrievalResponseV1 {
631 outcome: lexical_outcome_transport(artifact.proof.outcome()),
632 proof,
633 },
634 &state,
635 &request_id.0,
636 )
637}
638
639async fn retrieve_hybrid(
640 State(state): State<Arc<ServerState>>,
641 Extension(request_id): Extension<RequestId>,
642 request: Request,
643) -> Result<Response, ApiError> {
644 let request: HybridRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
645 let lexical_timeout =
646 requested_lexical_timeout(request.lexical.timeout_ms, &state, &request_id.0)?;
647 let vector_timeout =
648 requested_retrieval_timeout(request.vector.timeout_ms, &state, &request_id.0)?;
649 let lexical_request = lexical_request(request.lexical, &request_id.0)?;
650 let vector_request = exact_request(request.vector, &request_id.0)?;
651 let hybrid_request = HybridRequest {
652 lexical_weight: request.lexical_weight,
653 vector_weight: request.vector_weight,
654 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(&request_id.0))?,
655 };
656 if hybrid_request.limit > state.limits.lexical_retrieval.max_returned {
657 return Err(ApiError::limit(&request_id.0));
658 }
659 let mut lexical_limits = state.limits.lexical_retrieval.clone();
660 lexical_limits.timeout = lexical_timeout;
661 let mut vector_limits = state.limits.exact_retrieval.clone();
662 vector_limits.timeout = vector_timeout;
663 let maintenance = witness_maintenance_limits(
664 state.limits.witness_bytes,
665 lexical_timeout
666 .checked_add(vector_timeout)
667 .unwrap_or(Duration::MAX),
668 );
669 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
670 engine.retrieve_hybrid_with_proof_with_limits(
671 &lexical_request,
672 &lexical_limits,
673 &vector_request,
674 &vector_limits,
675 &hybrid_request,
676 &maintenance,
677 )
678 })
679 .await?;
680 let proof = hybrid_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
681 bounded_json(
682 &HybridRetrievalResponseV1 {
683 outcome: hybrid_outcome_transport(artifact.proof.outcome()),
684 proof,
685 },
686 &state,
687 &request_id.0,
688 )
689}
690
691async fn download_witness(
692 State(state): State<Arc<ServerState>>,
693 Extension(request_id): Extension<RequestId>,
694 request: Request,
695) -> Result<Response, ApiError> {
696 let (sequence, expected_digest) =
697 parse_witness_path(request.uri().path()).ok_or_else(|| ApiError::invalid(&request_id.0))?;
698 let path = state
699 .data_dir
700 .join("snapshots")
701 .join(format!("snapshot-{sequence:020}.hysnap"));
702 let permit = Arc::clone(&state.admission)
703 .try_acquire_owned()
704 .map_err(|_| busy(&request_id.0))?;
705 let verification_path = path.clone();
706 let verification_limits = SnapshotReadLimits {
707 file_bytes: state.limits.witness_bytes,
708 entries: state.limits.witness_bytes,
709 decoded_bytes: state.limits.witness_bytes,
710 };
711 let verified = tokio::task::spawn_blocking(move || {
712 open_verified_snapshot_with_limits(
713 verification_path,
714 &verification_limits,
715 WITNESS_VERIFICATION_TIMEOUT,
716 )
717 })
718 .await;
719 let (verified_file, info) = match verified {
720 Ok(Ok(verified)) => verified,
721 Ok(Err(SnapshotError::Io(source))) if source.kind() == std::io::ErrorKind::NotFound => {
722 return Err(not_found(&request_id.0));
723 }
724 Ok(Err(
725 SnapshotError::FileLimitExceeded { .. }
726 | SnapshotError::EntryLimitExceeded { .. }
727 | SnapshotError::DecodedBytesLimitExceeded { .. },
728 )) => {
729 return Err(ApiError::result_too_large(&request_id.0));
730 }
731 Ok(Err(source)) if source.is_timeout() => {
732 return Err(ApiError::timeout(&request_id.0));
733 }
734 Ok(Err(_)) | Err(_) => return Err(ApiError::internal(&request_id.0)),
735 };
736 if info.checkpoint_sequence != sequence || info.snapshot_digest != expected_digest {
737 return Err(not_found(&request_id.0));
738 }
739 let stream = ReaderStream::new(
740 AdmittedFile {
741 file: tokio::fs::File::from_std(verified_file),
742 _permit: permit,
743 }
744 .take(info.file_bytes),
745 );
746 Response::builder()
747 .status(StatusCode::OK)
748 .header(header::CONTENT_TYPE, "application/octet-stream")
749 .header(header::CONTENT_LENGTH, info.file_bytes)
750 .header(
751 "digest",
752 format!("blake3={}", encode_hex(&info.snapshot_digest)),
753 )
754 .body(Body::from_stream(stream))
755 .map_err(|_| ApiError::internal(&request_id.0))
756}
757
758async fn parse_json<T: DeserializeOwned>(
759 request: Request,
760 state: &ServerState,
761 request_id: &str,
762) -> Result<T, ApiError> {
763 if !is_json_content_type(request.headers()) {
764 return Err(ApiError::new(
765 StatusCode::UNSUPPORTED_MEDIA_TYPE,
766 "unsupported_media_type",
767 "content type must be application/json",
768 request_id,
769 ));
770 }
771 let bytes = tokio::time::timeout(
772 state.limits.request_body_timeout,
773 body::to_bytes(request.into_body(), state.limits.request_body_bytes),
774 )
775 .await
776 .map_err(|_| {
777 ApiError::new(
778 StatusCode::REQUEST_TIMEOUT,
779 "timeout",
780 "request body deadline elapsed without starting an operation",
781 request_id,
782 )
783 })?
784 .map_err(|_| ApiError::payload_too_large(request_id))?;
785 if bytes.is_empty() {
786 return Err(ApiError::invalid(request_id));
787 }
788 let value: serde_json::Value =
789 serde_json::from_slice(&bytes).map_err(|_| ApiError::invalid(request_id))?;
790 validate_json_shape(&value, state.limits.json_depth, state.limits.json_nodes)
791 .map_err(|()| ApiError::limit(request_id))?;
792 serde_json::from_value(value).map_err(|_| ApiError::invalid(request_id))
793}
794
795fn validate_json_shape(
796 root: &serde_json::Value,
797 maximum_depth: usize,
798 maximum_nodes: usize,
799) -> Result<(), ()> {
800 let mut stack = vec![(root, 0_usize)];
801 let mut nodes = 0_usize;
802 while let Some((value, depth)) = stack.pop() {
803 nodes = nodes.checked_add(1).ok_or(())?;
804 if nodes > maximum_nodes || depth > maximum_depth {
805 return Err(());
806 }
807 match value {
808 serde_json::Value::Array(values) => {
809 let next_depth = depth.checked_add(1).ok_or(())?;
810 stack.extend(values.iter().map(|value| (value, next_depth)));
811 }
812 serde_json::Value::Object(values) => {
813 let next_depth = depth.checked_add(1).ok_or(())?;
814 stack.extend(values.values().map(|value| (value, next_depth)));
815 }
816 serde_json::Value::Null
817 | serde_json::Value::Bool(_)
818 | serde_json::Value::Number(_)
819 | serde_json::Value::String(_) => {}
820 }
821 }
822 Ok(())
823}
824
825fn is_json_content_type(headers: &HeaderMap) -> bool {
826 let Some(value) = headers.get(header::CONTENT_TYPE) else {
827 return false;
828 };
829 let Ok(value) = value.to_str() else {
830 return false;
831 };
832 let media_type = value.split(';').next().unwrap_or_default().trim();
833 let media_type = media_type.to_ascii_lowercase();
834 media_type == "application/json"
835 || (media_type.starts_with("application/") && media_type.ends_with("+json"))
836}
837
838fn validate_batch(length: usize, state: &ServerState, request_id: &str) -> Result<(), ApiError> {
839 if length == 0 {
840 return Err(ApiError::invalid(request_id));
841 }
842 if length > state.limits.batch_items {
843 return Err(ApiError::limit(request_id));
844 }
845 Ok(())
846}
847
848fn parse_transaction_id(value: Option<&str>, request_id: &str) -> Result<Uuid, ApiError> {
849 value.map_or_else(
850 || Ok(Uuid::now_v7()),
851 |value| Uuid::parse_str(value).map_err(|_| ApiError::invalid(request_id)),
852 )
853}
854
855fn requested_timeout(
856 requested_ms: Option<u64>,
857 state: &ServerState,
858 request_id: &str,
859) -> Result<Duration, ApiError> {
860 let maximum_ms = u64::try_from(state.limits.query.timeout.as_millis()).unwrap_or(u64::MAX);
861 let requested_ms = requested_ms.unwrap_or(maximum_ms);
862 if requested_ms == 0 {
863 return Err(ApiError::invalid(request_id));
864 }
865 if requested_ms > maximum_ms {
866 return Err(ApiError::limit(request_id));
867 }
868 Ok(Duration::from_millis(requested_ms))
869}
870
871fn requested_retrieval_timeout(
872 requested_ms: Option<u64>,
873 state: &ServerState,
874 request_id: &str,
875) -> Result<Duration, ApiError> {
876 let maximum_ms =
877 u64::try_from(state.limits.exact_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
878 let requested_ms = requested_ms.unwrap_or(maximum_ms);
879 if requested_ms == 0 {
880 return Err(ApiError::invalid(request_id));
881 }
882 if requested_ms > maximum_ms {
883 return Err(ApiError::limit(request_id));
884 }
885 Ok(Duration::from_millis(requested_ms))
886}
887
888fn requested_lexical_timeout(
889 requested_ms: Option<u64>,
890 state: &ServerState,
891 request_id: &str,
892) -> Result<Duration, ApiError> {
893 let maximum_ms =
894 u64::try_from(state.limits.lexical_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
895 let requested_ms = requested_ms.unwrap_or(maximum_ms);
896 if requested_ms == 0 {
897 return Err(ApiError::invalid(request_id));
898 }
899 if requested_ms > maximum_ms {
900 return Err(ApiError::limit(request_id));
901 }
902 Ok(Duration::from_millis(requested_ms))
903}
904
905fn witness_maintenance_limits(witness_bytes: u64, timeout: Duration) -> MaintenanceLimits {
906 MaintenanceLimits {
907 timeout: WITNESS_VERIFICATION_TIMEOUT.min(timeout),
908 snapshot: SnapshotReadLimits {
909 file_bytes: witness_bytes,
910 entries: witness_bytes,
911 decoded_bytes: witness_bytes,
912 },
913 }
914}
915
916async fn with_engine<T, F>(
917 state: Arc<ServerState>,
918 request_id: &str,
919 operation: F,
920) -> Result<T, ApiError>
921where
922 T: Send + 'static,
923 F: FnOnce(&mut HyphaeEngine) -> Result<T, EngineError> + Send + 'static,
924{
925 if !state.ready.load(Ordering::Acquire) {
926 return Err(ApiError::unavailable(request_id));
927 }
928 let _permit = Arc::clone(&state.admission)
929 .try_acquire_owned()
930 .map_err(|_| busy(request_id))?;
931 let engine = Arc::clone(&state.engine);
932 let result = tokio::task::spawn_blocking(move || {
933 let mut engine = engine.lock().map_err(|_| EngineTaskError::Poisoned)?;
934 operation(&mut engine).map_err(EngineTaskError::Engine)
935 })
936 .await;
937 match result {
938 Ok(Ok(value)) => Ok(value),
939 Ok(Err(EngineTaskError::Engine(source))) => {
940 if engine_error_requires_recovery(&source) {
941 state.ready.store(false, Ordering::Release);
942 }
943 Err(ApiError::from_engine(source, request_id))
944 }
945 Ok(Err(EngineTaskError::Poisoned)) | Err(_) => {
946 state.ready.store(false, Ordering::Release);
947 Err(ApiError::internal(request_id))
948 }
949 }
950}
951
952async fn with_bounded_query<T, F>(
953 state: Arc<ServerState>,
954 request_id: &str,
955 operation: F,
956) -> Result<T, ApiError>
957where
958 T: Send + 'static,
959 F: FnOnce(&mut HyphaeEngine) -> Result<T, BoundedEngineQueryError> + Send + 'static,
960{
961 if !state.ready.load(Ordering::Acquire) {
962 return Err(ApiError::unavailable(request_id));
963 }
964 let _permit = Arc::clone(&state.admission)
965 .try_acquire_owned()
966 .map_err(|_| busy(request_id))?;
967 let engine = Arc::clone(&state.engine);
968 let result = tokio::task::spawn_blocking(move || {
969 let mut engine = engine.lock().map_err(|_| BoundedQueryTaskError::Poisoned)?;
970 operation(&mut engine).map_err(BoundedQueryTaskError::Query)
971 })
972 .await;
973 match result {
974 Ok(Ok(value)) => Ok(value),
975 Ok(Err(BoundedQueryTaskError::Query(source))) => {
976 if let BoundedEngineQueryError::Engine(engine) = &source
977 && engine_error_requires_recovery(engine)
978 {
979 state.ready.store(false, Ordering::Release);
980 }
981 Err(ApiError::from_bounded_query(source, request_id))
982 }
983 Ok(Err(BoundedQueryTaskError::Poisoned)) | Err(_) => {
984 state.ready.store(false, Ordering::Release);
985 Err(ApiError::internal(request_id))
986 }
987 }
988}
989
990enum EngineTaskError {
991 Engine(EngineError),
992 Poisoned,
993}
994
995enum BoundedQueryTaskError {
996 Query(BoundedEngineQueryError),
997 Poisoned,
998}
999
1000struct WriteOutcome {
1001 append: AppendOutcome,
1002 requires_recovery: bool,
1003}
1004
1005fn capture_write_outcome(
1006 result: Result<AppendOutcome, EngineError>,
1007) -> Result<WriteOutcome, EngineError> {
1008 match result {
1009 Ok(append) => Ok(WriteOutcome {
1010 append,
1011 requires_recovery: false,
1012 }),
1013 Err(EngineError::Storage(StorageError::CommittedButNotIndexed { receipt, .. })) => {
1014 Ok(WriteOutcome {
1015 append: AppendOutcome::Committed(receipt),
1016 requires_recovery: true,
1017 })
1018 }
1019 Err(source) => Err(source),
1020 }
1021}
1022
1023fn engine_error_requires_recovery(error: &EngineError) -> bool {
1024 if matches!(
1025 error,
1026 EngineError::Proof(ProofError::ProofLimitExceeded { .. } | ProofError::LengthOverflow)
1027 ) {
1028 return false;
1029 }
1030 match error {
1031 EngineError::Storage(StorageError::Index { source }) => {
1032 materialized_index_error_requires_recovery(source)
1033 }
1034 EngineError::Storage(StorageError::Snapshot { source }) => {
1035 snapshot_error_requires_recovery(source)
1036 }
1037 EngineError::Storage(
1038 StorageError::CommittedButNotIndexed { .. }
1039 | StorageError::StaleIndex
1040 | StorageError::DataDirectory(_)
1041 | StorageError::Log(LogError::Poisoned),
1042 )
1043 | EngineError::Proof(_) => true,
1044 _ => false,
1045 }
1046}
1047
1048fn snapshot_error_requires_recovery(error: &SnapshotError) -> bool {
1049 if error.is_timeout() || error.storage_limit().is_some() {
1050 return false;
1051 }
1052 match error {
1053 SnapshotError::FileLimitExceeded { .. }
1054 | SnapshotError::EntryLimitExceeded { .. }
1055 | SnapshotError::DecodedBytesLimitExceeded { .. } => false,
1056 SnapshotError::Index { source } => materialized_index_error_requires_recovery(source),
1057 _ => true,
1058 }
1059}
1060
1061fn materialized_index_error_requires_recovery(error: &MaterializedIndexError) -> bool {
1062 !matches!(
1063 error,
1064 MaterializedIndexError::Vector(_)
1065 | MaterializedIndexError::UnknownVectorSpace { .. }
1066 | MaterializedIndexError::VectorSpaceConflict { .. }
1067 | MaterializedIndexError::Lexical(_)
1068 | MaterializedIndexError::LexicalIndexConflict { .. }
1069 | MaterializedIndexError::UnknownLexicalIndex { .. }
1070 | MaterializedIndexError::VectorCandidateBudgetExceeded { .. }
1071 | MaterializedIndexError::VectorByteBudgetExceeded { .. }
1072 )
1073}
1074
1075fn proof_transport(
1076 artifact: &ResultProofArtifact,
1077 state: &ServerState,
1078 request_id: &str,
1079) -> Result<ProofV1, ApiError> {
1080 let encoded = artifact
1081 .proof
1082 .to_bytes()
1083 .map_err(|source| ApiError::from_engine(EngineError::Proof(source), request_id))?;
1084 if encoded.len() > state.limits.proof_bytes
1085 || artifact.snapshot.file_bytes > state.limits.witness_bytes
1086 {
1087 return Err(ApiError::result_too_large(request_id));
1088 }
1089 let anchor = artifact.proof.anchor();
1090 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
1091 Ok(ProofV1 {
1092 encoding: "base64".to_owned(),
1093 data: BASE64.encode(encoded),
1094 proof_digest: encode_hex(&artifact.proof.proof_digest()),
1095 anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
1096 checkpoint_sequence: anchor.checkpoint_sequence,
1097 checkpoint_digest: anchor
1098 .checkpoint_digest
1099 .as_ref()
1100 .map(|digest| encode_hex(digest)),
1101 snapshot_digest: snapshot_digest.clone(),
1102 witness: WitnessV1 {
1103 path: format!(
1104 "/v1/witnesses/{}/{}",
1105 anchor.checkpoint_sequence, snapshot_digest
1106 ),
1107 file_bytes: artifact.snapshot.file_bytes,
1108 },
1109 })
1110}
1111
1112fn retrieval_proof_transport(
1113 artifact: &ExactRetrievalProofArtifact,
1114 state: &ServerState,
1115 request_id: &str,
1116) -> Result<RetrievalProofV1, ApiError> {
1117 let encoded = artifact
1118 .proof
1119 .to_bytes()
1120 .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
1121 if encoded.len() > state.limits.proof_bytes
1122 || artifact.snapshot.file_bytes > state.limits.witness_bytes
1123 {
1124 return Err(ApiError::result_too_large(request_id));
1125 }
1126 let anchor = artifact.proof.anchor();
1127 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
1128 Ok(RetrievalProofV1 {
1129 encoding: "base64".to_owned(),
1130 data: BASE64.encode(encoded),
1131 proof_digest: encode_hex(&artifact.proof.proof_digest()),
1132 anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
1133 checkpoint_sequence: anchor.checkpoint_sequence,
1134 checkpoint_digest: anchor
1135 .checkpoint_digest
1136 .as_ref()
1137 .map(|digest| encode_hex(digest)),
1138 snapshot_digest: snapshot_digest.clone(),
1139 witness: WitnessV1 {
1140 path: format!(
1141 "/v1/witnesses/{}/{}",
1142 anchor.checkpoint_sequence, snapshot_digest
1143 ),
1144 file_bytes: artifact.snapshot.file_bytes,
1145 },
1146 })
1147}
1148
1149fn lexical_retrieval_proof_transport(
1150 artifact: &LexicalRetrievalProofArtifact,
1151 state: &ServerState,
1152 request_id: &str,
1153) -> Result<RetrievalProofV1, ApiError> {
1154 retrieval_proof_transport_parts(
1155 artifact.proof.to_bytes(),
1156 artifact.proof.proof_digest(),
1157 artifact.proof.anchor_digest(),
1158 artifact.proof.anchor(),
1159 artifact.snapshot.file_bytes,
1160 state,
1161 request_id,
1162 )
1163}
1164
1165fn hybrid_retrieval_proof_transport(
1166 artifact: &HybridRetrievalProofArtifact,
1167 state: &ServerState,
1168 request_id: &str,
1169) -> Result<RetrievalProofV1, ApiError> {
1170 retrieval_proof_transport_parts(
1171 artifact.proof.to_bytes(),
1172 artifact.proof.proof_digest(),
1173 artifact.proof.anchor_digest(),
1174 artifact.proof.anchor(),
1175 artifact.snapshot.file_bytes,
1176 state,
1177 request_id,
1178 )
1179}
1180
1181#[allow(clippy::too_many_arguments)]
1182fn retrieval_proof_transport_parts(
1183 encoded: Result<Vec<u8>, hyphae_engine::RetrievalProofError>,
1184 proof_digest: [u8; 32],
1185 anchor_digest: [u8; 32],
1186 anchor: &hyphae_engine::RetrievalProofAnchor,
1187 witness_bytes: u64,
1188 state: &ServerState,
1189 request_id: &str,
1190) -> Result<RetrievalProofV1, ApiError> {
1191 let encoded = encoded
1192 .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
1193 if encoded.len() > state.limits.proof_bytes || witness_bytes > state.limits.witness_bytes {
1194 return Err(ApiError::result_too_large(request_id));
1195 }
1196 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
1197 Ok(RetrievalProofV1 {
1198 encoding: "base64".to_owned(),
1199 data: BASE64.encode(encoded),
1200 proof_digest: encode_hex(&proof_digest),
1201 anchor_digest: encode_hex(&anchor_digest),
1202 checkpoint_sequence: anchor.checkpoint_sequence,
1203 checkpoint_digest: anchor
1204 .checkpoint_digest
1205 .as_ref()
1206 .map(|digest| encode_hex(digest)),
1207 snapshot_digest: snapshot_digest.clone(),
1208 witness: WitnessV1 {
1209 path: format!(
1210 "/v1/witnesses/{}/{}",
1211 anchor.checkpoint_sequence, snapshot_digest
1212 ),
1213 file_bytes: witness_bytes,
1214 },
1215 })
1216}
1217
1218fn exact_outcome_transport(outcome: &ExactRetrievalOutcome) -> ExactRetrievalOutcomeV1 {
1219 match outcome {
1220 ExactRetrievalOutcome::Matches {
1221 matches,
1222 scanned_candidates,
1223 } => ExactRetrievalOutcomeV1::Matches {
1224 matches: matches
1225 .iter()
1226 .map(|matched| ExactRetrievalMatchV1 {
1227 key_hex: encode_hex(&matched.key),
1228 score_nanos: matched.score_nanos,
1229 })
1230 .collect(),
1231 scanned_candidates: *scanned_candidates,
1232 },
1233 ExactRetrievalOutcome::Abstained(abstention) => ExactRetrievalOutcomeV1::Abstained {
1234 abstention: ExactAbstentionV1 {
1235 reason: match abstention.reason {
1236 ExactAbstentionReason::NoCandidates => ExactAbstentionReasonV1::NoCandidates,
1237 ExactAbstentionReason::BelowThreshold => {
1238 ExactAbstentionReasonV1::BelowThreshold
1239 }
1240 ExactAbstentionReason::Ambiguous => ExactAbstentionReasonV1::Ambiguous,
1241 },
1242 best_score_nanos: abstention.best_score_nanos,
1243 runner_up_score_nanos: abstention.runner_up_score_nanos,
1244 scanned_candidates: abstention.scanned_candidates,
1245 },
1246 },
1247 }
1248}
1249
1250fn lexical_outcome_transport(outcome: &LexicalOutcome) -> LexicalRetrievalOutcomeV1 {
1251 match outcome {
1252 LexicalOutcome::Matches {
1253 matches,
1254 scanned_documents,
1255 matched_documents,
1256 query_tokens,
1257 } => LexicalRetrievalOutcomeV1::Matches {
1258 matches: matches
1259 .iter()
1260 .map(|matched| LexicalRetrievalMatchV1 {
1261 key_hex: encode_hex(&matched.key),
1262 score_nanos: matched.score_nanos,
1263 terms: matched
1264 .terms
1265 .iter()
1266 .map(|term| LexicalTermContributionV1 {
1267 token: term.token.clone(),
1268 document_frequency: term.document_frequency,
1269 score_nanos: term.score_nanos,
1270 fields: term
1271 .fields
1272 .iter()
1273 .map(|field| LexicalFieldContributionV1 {
1274 path: field.path.segments().to_vec(),
1275 term_frequency: field.term_frequency,
1276 field_length: field.field_length,
1277 })
1278 .collect(),
1279 })
1280 .collect(),
1281 })
1282 .collect(),
1283 scanned_documents: *scanned_documents,
1284 matched_documents: *matched_documents,
1285 query_tokens: query_tokens.clone(),
1286 },
1287 LexicalOutcome::Abstained(abstention) => LexicalRetrievalOutcomeV1::Abstained {
1288 abstention: LexicalAbstentionV1 {
1289 reason: match abstention.reason {
1290 LexicalAbstentionReason::NoCandidates => {
1291 LexicalAbstentionReasonV1::NoCandidates
1292 }
1293 },
1294 scanned_documents: abstention.scanned_documents,
1295 query_tokens: abstention.query_tokens.clone(),
1296 },
1297 },
1298 }
1299}
1300
1301fn hybrid_outcome_transport(outcome: &HybridOutcome) -> HybridRetrievalOutcomeV1 {
1302 match outcome {
1303 HybridOutcome::Matches {
1304 matches,
1305 lexical_absence,
1306 vector_absence,
1307 } => HybridRetrievalOutcomeV1::Matches {
1308 matches: matches
1309 .iter()
1310 .map(|matched| HybridRetrievalMatchV1 {
1311 key_hex: encode_hex(&matched.key),
1312 explanation: HybridExplanationV1 {
1313 lexical_rank: matched.explanation.lexical_rank,
1314 lexical_score_nanos: matched.explanation.lexical_score_nanos,
1315 vector_rank: matched.explanation.vector_rank,
1316 vector_score_nanos: matched.explanation.vector_score_nanos,
1317 lexical_contribution: matched.explanation.lexical_contribution,
1318 vector_contribution: matched.explanation.vector_contribution,
1319 fusion_score: matched.explanation.fusion_score,
1320 final_rank: matched.explanation.final_rank,
1321 },
1322 })
1323 .collect(),
1324 lexical_absence: lexical_absence.map(hybrid_absence_transport),
1325 vector_absence: vector_absence.map(hybrid_absence_transport),
1326 },
1327 HybridOutcome::Abstained(abstention) => HybridRetrievalOutcomeV1::Abstained {
1328 abstention: HybridAbstentionV1 {
1329 lexical: hybrid_absence_transport(abstention.lexical),
1330 vector: hybrid_absence_transport(abstention.vector),
1331 },
1332 },
1333 }
1334}
1335
1336fn hybrid_absence_transport(absence: HybridBranchAbsence) -> HybridBranchAbsenceV1 {
1337 match absence {
1338 HybridBranchAbsence::LexicalNoCandidates => HybridBranchAbsenceV1::LexicalNoCandidates,
1339 HybridBranchAbsence::VectorNoCandidates => HybridBranchAbsenceV1::VectorNoCandidates,
1340 HybridBranchAbsence::VectorBelowThreshold => HybridBranchAbsenceV1::VectorBelowThreshold,
1341 HybridBranchAbsence::VectorAmbiguous => HybridBranchAbsenceV1::VectorAmbiguous,
1342 }
1343}
1344
1345fn receipt(outcome: AppendOutcome) -> CommitReceiptV1 {
1346 let (status, receipt) = match outcome {
1347 AppendOutcome::Committed(receipt) => ("committed", receipt),
1348 AppendOutcome::Existing(receipt) => ("existing", receipt),
1349 };
1350 CommitReceiptV1 {
1351 status: status.to_owned(),
1352 transaction_id: receipt.transaction_id.to_string(),
1353 commit_sequence: receipt.commit_sequence,
1354 commit_digest: encode_hex(&receipt.commit_digest),
1355 transaction_digest: encode_hex(&receipt.transaction_digest),
1356 }
1357}
1358
1359fn bounded_json<T: Serialize>(
1360 value: &T,
1361 state: &ServerState,
1362 request_id: &str,
1363) -> Result<Response, ApiError> {
1364 let encoded = serde_json::to_vec(value).map_err(|_| ApiError::internal(request_id))?;
1365 if encoded.len() > state.limits.response_bytes {
1366 return Err(ApiError::result_too_large(request_id));
1367 }
1368 Response::builder()
1369 .status(StatusCode::OK)
1370 .header(header::CONTENT_TYPE, "application/json")
1371 .header(header::CONTENT_LENGTH, encoded.len())
1372 .body(Body::from(encoded))
1373 .map_err(|_| ApiError::internal(request_id))
1374}
1375
1376fn parse_witness_path(path: &str) -> Option<(u64, [u8; 32])> {
1377 let suffix = path.strip_prefix("/v1/witnesses/")?;
1378 let mut components = suffix.split('/');
1379 let sequence = components.next()?.parse().ok()?;
1380 let digest = components.next()?;
1381 if components.next().is_some()
1382 || digest.len() != 64
1383 || !digest
1384 .bytes()
1385 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1386 {
1387 return None;
1388 }
1389 let decoded = decode_key_hex(digest).ok()?;
1390 decoded.try_into().ok().map(|digest| (sequence, digest))
1391}
1392
1393fn request_id(request: &Request) -> String {
1394 request
1395 .extensions()
1396 .get::<RequestId>()
1397 .map_or_else(|| Uuid::now_v7().to_string(), |value| value.0.clone())
1398}
1399
1400fn busy(request_id: &str) -> ApiError {
1401 ApiError::new(
1402 StatusCode::TOO_MANY_REQUESTS,
1403 "busy",
1404 "concurrent operation admission limit reached",
1405 request_id,
1406 )
1407}
1408
1409fn not_found(request_id: &str) -> ApiError {
1410 ApiError::new(
1411 StatusCode::NOT_FOUND,
1412 "not_found",
1413 "requested version 1 resource does not exist",
1414 request_id,
1415 )
1416}
1417
1418async fn route_not_found(Extension(request_id): Extension<RequestId>) -> ApiError {
1419 not_found(&request_id.0)
1420}
1421
1422async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> ApiError {
1423 ApiError::new(
1424 StatusCode::METHOD_NOT_ALLOWED,
1425 "method_not_allowed",
1426 "HTTP method is not defined for this version 1 route",
1427 request_id.0,
1428 )
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use std::{
1434 error::Error,
1435 fs,
1436 io::Write as _,
1437 net::Ipv4Addr,
1438 path::PathBuf,
1439 sync::{Arc, atomic::Ordering},
1440 time::Duration,
1441 };
1442
1443 use axum::{
1444 body::Body,
1445 http::{Request, header},
1446 };
1447 use serde_json::Value;
1448 use tokio::{
1449 io::{AsyncReadExt as _, AsyncWriteExt as _},
1450 net::TcpStream,
1451 sync::oneshot,
1452 };
1453 use tokio_util::io::ReaderStream;
1454 use tower::ServiceExt;
1455
1456 use hyphae_engine::EngineError;
1457 use hyphae_storage::{AppendOutcome, CommitReceipt, MaterializedIndexError, StorageError};
1458
1459 use super::{HyphaeServer, ServerConfig, StatusCode, body, capture_write_outcome};
1460 use crate::{BearerToken, ServerConfigError};
1461
1462 struct TestDirectory {
1463 path: PathBuf,
1464 }
1465
1466 impl TestDirectory {
1467 fn create(name: &str) -> Result<Self, Box<dyn Error>> {
1468 let path = std::env::temp_dir().join(format!(
1469 "hyphae-server-{name}-{}-{}",
1470 std::process::id(),
1471 uuid::Uuid::now_v7()
1472 ));
1473 fs::create_dir_all(&path)?;
1474 Ok(Self { path })
1475 }
1476 }
1477
1478 impl Drop for TestDirectory {
1479 fn drop(&mut self) {
1480 let _ignored = fs::remove_dir_all(&self.path);
1481 }
1482 }
1483
1484 #[test]
1485 fn remote_bind_is_rejected_before_socket_bind() -> Result<(), Box<dyn Error>> {
1486 let temporary = TestDirectory::create("remote-rejected")?;
1487 let mut config = ServerConfig::new(&temporary.path);
1488 config.bind = (Ipv4Addr::UNSPECIFIED, 8_787).into();
1489 assert!(matches!(
1490 HyphaeServer::open(config),
1491 Err(crate::ServerError::Configuration(
1492 ServerConfigError::RemoteBindRequiresAuthentication { .. }
1493 ))
1494 ));
1495 Ok(())
1496 }
1497
1498 #[test]
1499 fn bearer_tokens_require_visible_header_safe_entropy() {
1500 assert!(BearerToken::new("short").is_err());
1501 assert!(BearerToken::new("0123456789abcdef0123456789abcde\n").is_err());
1502 assert!(BearerToken::new("0123456789abcdef0123456789abcdef").is_ok());
1503 }
1504
1505 #[test]
1506 fn durable_unmaterialized_commit_keeps_its_public_receipt() -> Result<(), Box<dyn Error>> {
1507 let receipt = CommitReceipt {
1508 transaction_id: uuid::Uuid::now_v7(),
1509 commit_sequence: 9,
1510 commit_digest: [7; 32],
1511 transaction_digest: [8; 32],
1512 };
1513 let outcome = capture_write_outcome(Err(EngineError::Storage(
1514 StorageError::CommittedButNotIndexed {
1515 receipt,
1516 source: Box::new(MaterializedIndexError::MalformedCheckpoint),
1517 },
1518 )))?;
1519 assert!(outcome.requires_recovery);
1520 assert!(matches!(
1521 outcome.append,
1522 AppendOutcome::Committed(actual) if actual == receipt
1523 ));
1524 Ok(())
1525 }
1526
1527 #[tokio::test]
1528 async fn oversized_witness_is_rejected_by_metadata_before_hashing() -> Result<(), Box<dyn Error>>
1529 {
1530 let temporary = TestDirectory::create("oversized-witness")?;
1531 let mut config = ServerConfig::new(&temporary.path);
1532 config.limits.witness_bytes = 8;
1533 let app = HyphaeServer::open(config)?.test_router();
1534 let witness_path = temporary
1535 .path
1536 .join("snapshots")
1537 .join("snapshot-00000000000000000001.hysnap");
1538 fs::write(&witness_path, [0_u8; 9])?;
1539
1540 let response = app
1541 .oneshot(
1542 Request::builder()
1543 .uri(format!("/v1/witnesses/1/{}", "00".repeat(32)))
1544 .body(Body::empty())?,
1545 )
1546 .await?;
1547 assert_error(response, StatusCode::PAYLOAD_TOO_LARGE, "result_too_large").await?;
1548 Ok(())
1549 }
1550
1551 #[tokio::test]
1552 async fn snapshot_policy_failure_does_not_drop_server_readiness() -> Result<(), Box<dyn Error>>
1553 {
1554 let temporary = TestDirectory::create("snapshot-policy-readiness")?;
1555 let mut config = ServerConfig::new(&temporary.path);
1556 config.limits.witness_bytes = 1;
1557 let server = HyphaeServer::open(config)?;
1558 let app = server.test_router();
1559
1560 let put = app
1561 .clone()
1562 .oneshot(json_request(
1563 "/v1/kv/put",
1564 r#"{"records":[{"key_hex":"61","value":1}]}"#,
1565 None,
1566 )?)
1567 .await?;
1568 assert_eq!(put.status(), StatusCode::OK);
1569
1570 let rejected = app
1571 .clone()
1572 .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1573 .await?;
1574 assert_error(rejected, StatusCode::PAYLOAD_TOO_LARGE, "result_too_large").await?;
1575 assert!(server.state.ready.load(Ordering::Acquire));
1576
1577 let ready = app
1578 .oneshot(
1579 Request::builder()
1580 .uri("/v1/health/ready")
1581 .body(Body::empty())?,
1582 )
1583 .await?;
1584 assert_eq!(ready.status(), StatusCode::OK);
1585 Ok(())
1586 }
1587
1588 #[tokio::test]
1589 async fn witness_stream_stops_at_verified_length_after_late_append()
1590 -> Result<(), Box<dyn Error>> {
1591 let temporary = TestDirectory::create("witness-stream-length")?;
1592 let app = HyphaeServer::open(ServerConfig::new(&temporary.path))?.test_router();
1593 let put = app
1594 .clone()
1595 .oneshot(json_request(
1596 "/v1/kv/put",
1597 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000011","records":[{"key_hex":"61","value":1}]}"#,
1598 None,
1599 )?)
1600 .await?;
1601 assert_eq!(put.status(), StatusCode::OK);
1602
1603 let get = app
1604 .clone()
1605 .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1606 .await?;
1607 assert_eq!(get.status(), StatusCode::OK);
1608 let get: Value = serde_json::from_slice(&response_bytes(get).await?)?;
1609 let witness_path = get["proof"]["witness"]["path"]
1610 .as_str()
1611 .ok_or("missing witness path")?
1612 .to_owned();
1613 let witness = app
1614 .oneshot(Request::builder().uri(witness_path).body(Body::empty())?)
1615 .await?;
1616 assert_eq!(witness.status(), StatusCode::OK);
1617 let verified_length = witness
1618 .headers()
1619 .get(header::CONTENT_LENGTH)
1620 .ok_or("missing witness content length")?
1621 .to_str()?
1622 .parse::<usize>()?;
1623
1624 let mut snapshots = fs::read_dir(temporary.path.join("snapshots"))?;
1625 let snapshot_path = snapshots.next().ok_or("missing local snapshot")??.path();
1626 assert!(snapshots.next().is_none());
1627 let suffix = b"late-unverified-growth";
1628 let mut snapshot = fs::OpenOptions::new().append(true).open(snapshot_path)?;
1629 snapshot.write_all(suffix)?;
1630 snapshot.sync_all()?;
1631 drop(snapshot);
1632
1633 let streamed = response_bytes(witness).await?;
1634 assert_eq!(streamed.len(), verified_length);
1635 assert!(!streamed.ends_with(suffix));
1636 Ok(())
1637 }
1638
1639 #[tokio::test]
1640 #[allow(clippy::too_many_lines)]
1641 async fn authenticated_put_get_and_witness_are_contract_shaped() -> Result<(), Box<dyn Error>> {
1642 let temporary = TestDirectory::create("authenticated-flow")?;
1643 let secret = "correct-hyphae-token-material-0001";
1644 let mut config = ServerConfig::new(&temporary.path);
1645 config.bearer_token = Some(BearerToken::new(secret)?);
1646 config.limits.concurrent_operations = 1;
1647 let app = HyphaeServer::open(config)?.test_router();
1648
1649 let unauthorized = app
1650 .clone()
1651 .oneshot(json_request("/v1/kv/put", r#"{"records":[]}"#, None)?)
1652 .await?;
1653 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
1654 assert!(unauthorized.headers().contains_key("x-request-id"));
1655
1656 let wrong = app
1657 .clone()
1658 .oneshot(json_request(
1659 "/v1/kv/put",
1660 r#"{"records":[]}"#,
1661 Some("incorrect-hyphae-token-material-001"),
1662 )?)
1663 .await?;
1664 assert_error(wrong, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1665
1666 let duplicate_header = app
1667 .clone()
1668 .oneshot(
1669 Request::builder()
1670 .method("POST")
1671 .uri("/v1/query")
1672 .header("content-type", "application/json")
1673 .header("authorization", format!("Bearer {secret}"))
1674 .header("authorization", format!("Bearer {secret}"))
1675 .body(Body::from(r#"{"limit":1}"#))?,
1676 )
1677 .await?;
1678 assert_error(duplicate_header, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1679
1680 let put = app
1681 .clone()
1682 .oneshot(json_request(
1683 "/v1/kv/put",
1684 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1685 Some(secret),
1686 )?)
1687 .await?;
1688 assert_eq!(put.status(), StatusCode::OK);
1689 let put: Value = serde_json::from_slice(&response_bytes(put).await?)?;
1690 assert_eq!(put["status"], "committed");
1691
1692 let retry = app
1693 .clone()
1694 .oneshot(json_request(
1695 "/v1/kv/put",
1696 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1697 Some(secret),
1698 )?)
1699 .await?;
1700 assert_eq!(retry.status(), StatusCode::OK);
1701 let retry: Value = serde_json::from_slice(&response_bytes(retry).await?)?;
1702 assert_eq!(retry["status"], "existing");
1703
1704 let conflict = app
1705 .clone()
1706 .oneshot(json_request(
1707 "/v1/kv/put",
1708 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":8}}]}"#,
1709 Some(secret),
1710 )?)
1711 .await?;
1712 assert_error(conflict, StatusCode::CONFLICT, "idempotency_conflict").await?;
1713
1714 let get = app
1715 .clone()
1716 .oneshot(json_request(
1717 "/v1/kv/get",
1718 r#"{"key_hex":"61"}"#,
1719 Some(secret),
1720 )?)
1721 .await?;
1722 assert_eq!(get.status(), StatusCode::OK);
1723 let get: Value = serde_json::from_slice(&response_bytes(get).await?)?;
1724 assert_eq!(get["found"], true);
1725 assert_eq!(get["record"]["value"]["score"], 7);
1726 assert_eq!(get["proof"]["encoding"], "base64");
1727 let witness_path = get["proof"]["witness"]["path"]
1728 .as_str()
1729 .ok_or("missing witness path")?;
1730
1731 let witness = app
1732 .clone()
1733 .oneshot(
1734 Request::builder()
1735 .uri(witness_path)
1736 .header("authorization", format!("Bearer {secret}"))
1737 .body(Body::empty())?,
1738 )
1739 .await?;
1740 assert_eq!(witness.status(), StatusCode::OK);
1741 assert!(witness.headers().contains_key("digest"));
1742 let blocked_while_stream_is_live = app
1743 .clone()
1744 .oneshot(json_request("/v1/query", r#"{"limit":1}"#, Some(secret))?)
1745 .await?;
1746 assert_error(
1747 blocked_while_stream_is_live,
1748 StatusCode::TOO_MANY_REQUESTS,
1749 "busy",
1750 )
1751 .await?;
1752 assert!(response_bytes(witness).await?.starts_with(b"HYSNAP01"));
1753
1754 let query = app
1755 .oneshot(json_request("/v1/query", r#"{"limit":10}"#, Some(secret))?)
1756 .await?;
1757 assert_eq!(query.status(), StatusCode::OK);
1758 let query: Value = serde_json::from_slice(&response_bytes(query).await?)?;
1759 assert_eq!(query["rows"].as_array().map(Vec::len), Some(1));
1760 assert_eq!(query["proof"]["encoding"], "base64");
1761 Ok(())
1762 }
1763
1764 #[tokio::test]
1765 async fn public_routes_and_limit_failures_never_emit_framework_text()
1766 -> Result<(), Box<dyn Error>> {
1767 let temporary = TestDirectory::create("limits")?;
1768 let mut config = ServerConfig::new(&temporary.path);
1769 config.limits.request_body_bytes = 1_024;
1770 config.limits.batch_items = 1;
1771 let app = HyphaeServer::open(config)?.test_router();
1772
1773 let capabilities = app
1774 .clone()
1775 .oneshot(
1776 Request::builder()
1777 .uri("/v1/capabilities")
1778 .body(Body::empty())?,
1779 )
1780 .await?;
1781 assert_eq!(capabilities.status(), StatusCode::OK);
1782 let capabilities: Value = serde_json::from_slice(&response_bytes(capabilities).await?)?;
1783 assert_eq!(capabilities["api_version"], "v1");
1784 assert_eq!(capabilities["limits"]["batch_items"], 1);
1785
1786 let too_many = app
1787 .clone()
1788 .oneshot(json_request(
1789 "/v1/kv/delete",
1790 r#"{"keys_hex":["61","62"]}"#,
1791 None,
1792 )?)
1793 .await?;
1794 assert_error(too_many, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1795
1796 let unsupported = app
1797 .clone()
1798 .oneshot(
1799 Request::builder()
1800 .method("POST")
1801 .uri("/v1/query")
1802 .header("content-type", "text/plain")
1803 .body(Body::from("{}"))?,
1804 )
1805 .await?;
1806 assert_error(
1807 unsupported,
1808 StatusCode::UNSUPPORTED_MEDIA_TYPE,
1809 "unsupported_media_type",
1810 )
1811 .await?;
1812
1813 let oversized = app
1814 .clone()
1815 .oneshot(json_request(
1816 "/v1/query",
1817 &format!(r#"{{"limit":1,"ignored":"{}"}}"#, "x".repeat(2_000)),
1818 None,
1819 )?)
1820 .await?;
1821 assert_error(
1822 oversized,
1823 StatusCode::PAYLOAD_TOO_LARGE,
1824 "payload_too_large",
1825 )
1826 .await?;
1827
1828 let missing = app
1829 .clone()
1830 .oneshot(Request::builder().uri("/v1/unknown").body(Body::empty())?)
1831 .await?;
1832 assert_error(missing, StatusCode::NOT_FOUND, "not_found").await?;
1833
1834 let wrong_method = app
1835 .oneshot(
1836 Request::builder()
1837 .method("DELETE")
1838 .uri("/v1/health/live")
1839 .body(Body::empty())?,
1840 )
1841 .await?;
1842 assert_error(
1843 wrong_method,
1844 StatusCode::METHOD_NOT_ALLOWED,
1845 "method_not_allowed",
1846 )
1847 .await?;
1848 Ok(())
1849 }
1850
1851 #[tokio::test]
1852 async fn vector_lexical_and_hybrid_routes_return_proof_bearing_results()
1853 -> Result<(), Box<dyn Error>> {
1854 let temporary = TestDirectory::create("retrieval-flow")?;
1855 let app = HyphaeServer::open(ServerConfig::new(&temporary.path))?.test_router();
1856
1857 for (path, payload) in [
1858 (
1859 "/v1/kv/put",
1860 r#"{"records":[{"key_hex":"616c706861","value":{"title":"Durable memory","body":"offline agent memory"}},{"key_hex":"62657461","value":{"title":"Fast search","body":"exact vector retrieval"}}]}"#,
1861 ),
1862 (
1863 "/v1/lexical-indexes/define",
1864 r#"{"lexical_index":{"name":"content","fields":[{"path":["title"],"weight_micros":2000000},{"path":["body"],"weight_micros":1000000}]}}"#,
1865 ),
1866 (
1867 "/v1/vector-spaces/define",
1868 r#"{"vector_space":{"name":"semantic","dimension":2,"metric":"cosine_q15_nanos"}}"#,
1869 ),
1870 (
1871 "/v1/vectors/put",
1872 r#"{"vector_space":"semantic","vectors":[{"key_hex":"616c706861","values":[32767,0]},{"key_hex":"62657461","values":[0,32767]}]}"#,
1873 ),
1874 ] {
1875 let response = app
1876 .clone()
1877 .oneshot(json_request(path, payload, None)?)
1878 .await?;
1879 assert_eq!(response.status(), StatusCode::OK, "{path}");
1880 }
1881
1882 let exact = app
1883 .clone()
1884 .oneshot(json_request(
1885 "/v1/retrieve/exact",
1886 r#"{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1887 None,
1888 )?)
1889 .await?;
1890 assert_eq!(exact.status(), StatusCode::OK);
1891 let exact: Value = serde_json::from_slice(&response_bytes(exact).await?)?;
1892 assert_eq!(exact["outcome"]["matches"][0]["key_hex"], "616c706861");
1893 assert_eq!(exact["proof"]["encoding"], "base64");
1894
1895 let lexical = app
1896 .clone()
1897 .oneshot(json_request(
1898 "/v1/retrieve/lexical",
1899 r#"{"lexical_index":"content","query":"durable memory","limit":2}"#,
1900 None,
1901 )?)
1902 .await?;
1903 assert_eq!(lexical.status(), StatusCode::OK);
1904 let lexical: Value = serde_json::from_slice(&response_bytes(lexical).await?)?;
1905 assert_eq!(lexical["outcome"]["matches"][0]["key_hex"], "616c706861");
1906 assert_eq!(lexical["proof"]["encoding"], "base64");
1907
1908 let hybrid = app
1909 .clone()
1910 .oneshot(json_request(
1911 "/v1/retrieve/hybrid",
1912 r#"{"lexical":{"lexical_index":"content","query":"durable memory","limit":2},"vector":{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0},"lexical_weight":1,"vector_weight":1,"limit":2}"#,
1913 None,
1914 )?)
1915 .await?;
1916 assert_eq!(hybrid.status(), StatusCode::OK);
1917 let hybrid: Value = serde_json::from_slice(&response_bytes(hybrid).await?)?;
1918 assert_eq!(hybrid["outcome"]["matches"][0]["key_hex"], "616c706861");
1919 assert_eq!(
1920 hybrid["outcome"]["matches"][0]["explanation"]["final_rank"],
1921 1
1922 );
1923 assert_eq!(hybrid["proof"]["encoding"], "base64");
1924
1925 let wrong_dimension = app
1926 .clone()
1927 .oneshot(json_request(
1928 "/v1/retrieve/exact",
1929 r#"{"vector_space":"semantic","query":[32767],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1930 None,
1931 )?)
1932 .await?;
1933 assert_error(wrong_dimension, StatusCode::BAD_REQUEST, "invalid_request").await?;
1934
1935 let empty_query = app
1936 .oneshot(json_request(
1937 "/v1/retrieve/lexical",
1938 r#"{"lexical_index":"content","query":"---","limit":2}"#,
1939 None,
1940 )?)
1941 .await?;
1942 assert_error(empty_query, StatusCode::BAD_REQUEST, "invalid_request").await?;
1943 Ok(())
1944 }
1945
1946 #[tokio::test]
1947 async fn shape_proof_and_admission_limits_fail_without_partial_results()
1948 -> Result<(), Box<dyn Error>> {
1949 let temporary = TestDirectory::create("bounded-work")?;
1950 let mut config = ServerConfig::new(&temporary.path);
1951 config.limits.json_depth = 3;
1952 config.limits.concurrent_operations = 1;
1953 config.limits.proof_bytes = 128;
1954 let server = HyphaeServer::open(config)?;
1955 let app = server.test_router();
1956
1957 let too_deep = app
1958 .clone()
1959 .oneshot(json_request(
1960 "/v1/query",
1961 r#"{"filter":{"op":"not","filter":{"op":"not","filter":{"op":"not","filter":{"op":"match_all"}}}},"limit":1}"#,
1962 None,
1963 )?)
1964 .await?;
1965 assert_error(too_deep, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1966
1967 let put = app
1968 .clone()
1969 .oneshot(json_request(
1970 "/v1/kv/put",
1971 r#"{"records":[{"key_hex":"61","value":1}]}"#,
1972 None,
1973 )?)
1974 .await?;
1975 assert_eq!(put.status(), StatusCode::OK);
1976
1977 let proof_too_large = app
1978 .clone()
1979 .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1980 .await?;
1981 assert_error(
1982 proof_too_large,
1983 StatusCode::PAYLOAD_TOO_LARGE,
1984 "result_too_large",
1985 )
1986 .await?;
1987
1988 let permit = Arc::clone(&server.state.admission).try_acquire_owned()?;
1989 let busy = app
1990 .clone()
1991 .oneshot(json_request("/v1/query", r#"{"limit":1}"#, None)?)
1992 .await?;
1993 drop(permit);
1994 assert_error(busy, StatusCode::TOO_MANY_REQUESTS, "busy").await?;
1995
1996 server
1997 .state
1998 .ready
1999 .store(false, std::sync::atomic::Ordering::Release);
2000 let unavailable = app
2001 .oneshot(
2002 Request::builder()
2003 .uri("/v1/health/ready")
2004 .body(Body::empty())?,
2005 )
2006 .await?;
2007 assert_error(unavailable, StatusCode::SERVICE_UNAVAILABLE, "unavailable").await?;
2008 Ok(())
2009 }
2010
2011 #[tokio::test]
2012 async fn stalled_json_body_times_out_before_any_operation_starts() -> Result<(), Box<dyn Error>>
2013 {
2014 let temporary = TestDirectory::create("body-timeout")?;
2015 let mut config = ServerConfig::new(&temporary.path);
2016 config.limits.request_body_timeout = Duration::from_millis(5);
2017 let app = HyphaeServer::open(config)?.test_router();
2018 let (_writer, reader) = tokio::io::duplex(1);
2019 let response = app
2020 .oneshot(
2021 Request::builder()
2022 .method("POST")
2023 .uri("/v1/query")
2024 .header("content-type", "application/json")
2025 .body(Body::from_stream(ReaderStream::new(reader)))?,
2026 )
2027 .await?;
2028 assert_error(response, StatusCode::REQUEST_TIMEOUT, "timeout").await?;
2029 Ok(())
2030 }
2031
2032 #[tokio::test]
2033 async fn bound_server_stops_on_graceful_shutdown() -> Result<(), Box<dyn Error>> {
2034 let temporary = TestDirectory::create("graceful")?;
2035 let mut config = ServerConfig::new(&temporary.path);
2036 config.bind.set_port(0);
2037 let bound = HyphaeServer::open(config)?.bind().await?;
2038 let local_addr = bound.local_addr();
2039 assert_ne!(local_addr.port(), 0);
2040 let (send, receive) = oneshot::channel::<()>();
2041 let serving = tokio::spawn(bound.run_with_shutdown(async move {
2042 let _ignored = receive.await;
2043 }));
2044 let mut connection = TcpStream::connect(local_addr).await?;
2045 connection
2046 .write_all(
2047 b"GET /v1/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
2048 )
2049 .await?;
2050 let mut response = Vec::new();
2051 connection.read_to_end(&mut response).await?;
2052 assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n"));
2053 assert!(response.ends_with(br#"{"status":"live"}"#));
2054 let _ignored = send.send(());
2055 serving.await??;
2056 Ok(())
2057 }
2058
2059 fn json_request(
2060 uri: &str,
2061 body: &str,
2062 bearer: Option<&str>,
2063 ) -> Result<Request<Body>, axum::http::Error> {
2064 let mut request = Request::builder()
2065 .method("POST")
2066 .uri(uri)
2067 .header("content-type", "application/json");
2068 if let Some(bearer) = bearer {
2069 request = request.header("authorization", format!("Bearer {bearer}"));
2070 }
2071 request.body(Body::from(body.to_owned()))
2072 }
2073
2074 async fn response_bytes(
2075 response: axum::response::Response,
2076 ) -> Result<axum::body::Bytes, Box<dyn Error>> {
2077 Ok(body::to_bytes(response.into_body(), 64 * 1024 * 1024).await?)
2078 }
2079
2080 async fn assert_error(
2081 response: axum::response::Response,
2082 status: StatusCode,
2083 code: &str,
2084 ) -> Result<(), Box<dyn Error>> {
2085 assert_eq!(response.status(), status);
2086 let header_request_id = response
2087 .headers()
2088 .get("x-request-id")
2089 .and_then(|value| value.to_str().ok())
2090 .ok_or("missing request ID header")?
2091 .to_owned();
2092 let value: Value = serde_json::from_slice(&response_bytes(response).await?)?;
2093 assert_eq!(value["code"], code);
2094 assert_eq!(value["request_id"], header_request_id);
2095 Ok(())
2096 }
2097}