1pub(crate) use crate::application::json_input::{
2 json_bool_field, json_f32_field, json_string_field, json_usize_field,
3};
4pub(crate) use crate::application::{
5 AdminUseCases, CreateEdgeInput, CreateEntityOutput, CreateNodeGraphLinkInput, CreateNodeInput,
6 CreateNodeTableLinkInput, CreateRowInput, CreateVectorInput, DeleteEntityInput, EntityUseCases,
7 ExplainQueryInput, GraphCentralityInput, GraphClusteringInput, GraphCommunitiesInput,
8 GraphComponentsInput, GraphCyclesInput, GraphHitsInput, GraphNeighborhoodInput,
9 GraphPersonalizedPageRankInput, GraphShortestPathInput, GraphTopologicalSortInput,
10 GraphTraversalInput, GraphUseCases, InspectNativeArtifactInput, NativeUseCases,
11 PatchEntityInput, SearchHybridInput, SearchIvfInput, SearchSimilarInput, SearchTextInput,
12};
13use std::collections::BTreeMap;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::api::{RedDBOptions, RedDBResult};
19use crate::auth::middleware::{check_permission, AuthResult, AuthSource};
20use crate::auth::store::AuthStore;
21use crate::auth::Role;
22use crate::health::{HealthProvider, HealthState};
23use crate::json::{
24 from_str as json_from_str, to_string as json_to_string, Map, Value as JsonValue,
25};
26use crate::runtime::query_request::{
27 ParamValue, PreparedId, PreparedRegistry, QueryRequest as RuntimeQueryRequest,
28 QueryRequestExecutor,
29};
30use crate::runtime::{
31 RedDBRuntime, RuntimeFilter, RuntimeFilterValue, RuntimeGraphCentralityAlgorithm,
32 RuntimeGraphCentralityResult, RuntimeGraphClusteringResult, RuntimeGraphCommunityAlgorithm,
33 RuntimeGraphCommunityResult, RuntimeGraphComponentsMode, RuntimeGraphComponentsResult,
34 RuntimeGraphCyclesResult, RuntimeGraphDirection, RuntimeGraphHitsResult,
35 RuntimeGraphNeighborhoodResult, RuntimeGraphPathAlgorithm, RuntimeGraphPathResult,
36 RuntimeGraphPattern, RuntimeGraphProjection, RuntimeGraphTopologicalSortResult,
37 RuntimeGraphTraversalResult, RuntimeGraphTraversalStrategy, RuntimeIvfSearchResult,
38 RuntimeQueryResult, RuntimeQueryWeights, RuntimeStats, ScanPage,
39};
40use crate::storage::unified::devx::refs::{NodeRef, TableRef};
41use crate::storage::unified::{Metadata, MetadataValue};
42use crate::storage::{EntityData, EntityId, UnifiedEntity};
43use reddb_types::Value;
44use tokio_stream::wrappers::TcpListenerStream;
45use tonic::metadata::MetadataMap;
46use tonic::{Request, Response, Status};
47
48pub use reddb_grpc_proto as proto;
54
55use proto::red_db_server::{RedDb, RedDbServer};
56use proto::{
57 ask_stream_event, AskAnswerToken, AskReply, AskRequest, AskSources, AskStreamEvent,
58 BatchInsertChunk, BatchInsertReply, BatchQueryReply, BatchQueryRequest, BulkEntityReply,
59 Citation, CollectionRequest, CollectionsReply, DeleteEntityRequest, DeploymentProfileRequest,
60 Empty, EntityReply, ExecutePreparedRequest, ExportRequest, GraphProjectionUpsertRequest,
61 HealthReply, IndexNameRequest, IndexToggleRequest, JsonBulkCreateRequest, JsonCreateRequest,
62 JsonPayloadRequest, KvWatchEvent, KvWatchRequest, ManifestRequest, OperationReply,
63 PayloadReply, PrepareQueryReply, PrepareQueryRequest, QueryReply, QueryRequest, QueryValue,
64 ScanEntity, ScanReply, ScanRequest, StatsReply, TopologyReply, TopologyRequest,
65 UpdateEntityRequest, Validation, ValidationItem,
66};
67
68pub(crate) mod catalog_dispatch;
69pub(crate) mod control_support;
70mod entity_ops;
71mod input_support;
72pub(crate) mod scan_json;
73
74use self::catalog_dispatch::*;
75use self::control_support::*;
76use self::entity_ops::*;
77use self::input_support::*;
78use self::scan_json::*;
79
80#[derive(Debug, Clone)]
81pub struct GrpcServerOptions {
82 pub bind_addr: String,
83 pub tls: Option<GrpcTlsOptions>,
88}
89
90#[derive(Debug, Clone)]
96pub struct GrpcTlsOptions {
97 pub cert_pem: Vec<u8>,
99 pub key_pem: Vec<u8>,
101 pub client_ca_pem: Option<Vec<u8>>,
106}
107
108impl GrpcTlsOptions {
109 pub fn to_tonic_config(
113 &self,
114 ) -> Result<tonic::transport::ServerTlsConfig, Box<dyn std::error::Error>> {
115 let identity = tonic::transport::Identity::from_pem(&self.cert_pem, &self.key_pem);
116 let mut cfg = tonic::transport::ServerTlsConfig::new().identity(identity);
117 if let Some(ca_pem) = &self.client_ca_pem {
118 cfg = cfg.client_ca_root(tonic::transport::Certificate::from_pem(ca_pem));
119 }
120 Ok(cfg)
121 }
122}
123
124const GRPC_MAX_CONCURRENT_STREAMS: u32 = 256;
127
128const GRPC_CONCURRENCY_LIMIT_PER_CONNECTION: usize = 256;
131
132const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
135const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
136
137fn configured_transport_builder() -> tonic::transport::Server {
143 tonic::transport::Server::builder()
144 .max_concurrent_streams(Some(GRPC_MAX_CONCURRENT_STREAMS))
145 .concurrency_limit_per_connection(GRPC_CONCURRENCY_LIMIT_PER_CONNECTION)
146 .http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
147 .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
148}
149
150impl Default for GrpcServerOptions {
151 fn default() -> Self {
152 Self {
153 bind_addr: "127.0.0.1:55055".to_string(),
154 tls: None,
155 }
156 }
157}
158
159#[derive(Clone)]
160pub struct RedDBGrpcServer {
161 runtime: RedDBRuntime,
162 options: GrpcServerOptions,
163 auth_store: Arc<AuthStore>,
164 prepared_registry: Arc<PreparedRegistry>,
165 oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
171}
172
173impl RedDBGrpcServer {
174 pub fn new(runtime: RedDBRuntime) -> Self {
175 let auth_config = crate::auth::AuthConfig::default();
176 let auth_store = Arc::new(AuthStore::new(auth_config));
177 Self::with_options(runtime, GrpcServerOptions::default(), auth_store)
178 }
179
180 pub fn from_database_options(
181 db_options: RedDBOptions,
182 options: GrpcServerOptions,
183 ) -> RedDBResult<Self> {
184 let runtime = RedDBRuntime::with_options(db_options.clone())?;
186
187 let auth_store = if db_options.auth.vault_enabled {
188 let pager = runtime.db().store().pager().cloned().ok_or_else(|| {
192 crate::api::RedDBError::Internal(
193 "vault requires a paged database (persistent mode)".into(),
194 )
195 })?;
196 let store = AuthStore::with_vault(db_options.auth.clone(), pager)
197 .map_err(|e| crate::api::RedDBError::Internal(e.to_string()))?;
198 Arc::new(store)
199 } else {
200 Arc::new(AuthStore::new(db_options.auth.clone()))
201 };
202 auth_store.bootstrap_from_env();
203 Ok(Self::with_options(runtime, options, auth_store))
204 }
205
206 pub fn with_options(
207 runtime: RedDBRuntime,
208 options: GrpcServerOptions,
209 auth_store: Arc<AuthStore>,
210 ) -> Self {
211 runtime.set_auth_store(Arc::clone(&auth_store));
214 Self {
215 runtime,
216 options,
217 auth_store,
218 prepared_registry: Arc::new(PreparedRegistry::new()),
219 oauth_validator: None,
220 }
221 }
222
223 pub fn with_oauth_validator(mut self, validator: Arc<crate::auth::OAuthValidator>) -> Self {
229 self.oauth_validator = Some(validator);
230 self
231 }
232
233 pub fn oauth_validator(&self) -> Option<&Arc<crate::auth::OAuthValidator>> {
235 self.oauth_validator.as_ref()
236 }
237
238 pub fn runtime(&self) -> &RedDBRuntime {
239 &self.runtime
240 }
241
242 pub fn options(&self) -> &GrpcServerOptions {
243 &self.options
244 }
245
246 pub fn auth_store(&self) -> &Arc<AuthStore> {
247 &self.auth_store
248 }
249
250 pub(crate) fn with_listener_options(mut self, options: GrpcServerOptions) -> Self {
251 self.options = options;
252 self
253 }
254
255 fn grpc_runtime(&self) -> GrpcRuntime {
256 GrpcRuntime {
257 runtime: self.runtime.clone(),
258 auth_store: self.auth_store.clone(),
259 prepared_registry: Arc::clone(&self.prepared_registry),
260 oauth_validator: self.oauth_validator.clone(),
261 }
262 }
263
264 pub async fn serve(&self) -> Result<(), Box<dyn std::error::Error>> {
265 let addr = self.options.bind_addr.parse()?;
266 let mut builder = configured_transport_builder();
267 if let Some(tls) = &self.options.tls {
268 log_grpc_tls_identity(tls);
271 builder = builder.tls_config(tls.to_tonic_config()?)?;
272 }
273 builder
274 .add_service(Self::configured_service(self.grpc_runtime()))
275 .serve(addr)
276 .await?;
277 Ok(())
278 }
279
280 pub async fn serve_on(
281 &self,
282 listener: std::net::TcpListener,
283 ) -> Result<(), Box<dyn std::error::Error>> {
284 listener.set_nonblocking(true)?;
285 let listener = tokio::net::TcpListener::from_std(listener)?;
286 let incoming = TcpListenerStream::new(listener);
287 let mut builder = configured_transport_builder();
288 if let Some(tls) = &self.options.tls {
289 log_grpc_tls_identity(tls);
290 builder = builder.tls_config(tls.to_tonic_config()?)?;
291 }
292 builder
293 .add_service(Self::configured_service(self.grpc_runtime()))
294 .serve_with_incoming(incoming)
295 .await?;
296 Ok(())
297 }
298
299 pub(crate) async fn serve_router_demux(
306 &self,
307 rx: tokio::sync::mpsc::Receiver<tokio::net::TcpStream>,
308 ) -> Result<(), Box<dyn std::error::Error>> {
309 use tokio_stream::StreamExt;
310 let incoming = tokio_stream::wrappers::ReceiverStream::new(rx).map(Ok::<_, std::io::Error>);
311 let mut builder = configured_transport_builder();
312 if let Some(tls) = &self.options.tls {
313 log_grpc_tls_identity(tls);
314 builder = builder.tls_config(tls.to_tonic_config()?)?;
315 }
316 builder
317 .add_service(Self::configured_service(self.grpc_runtime()))
318 .serve_with_incoming(incoming)
319 .await?;
320 Ok(())
321 }
322
323 fn max_message_bytes() -> usize {
329 const DEFAULT: usize = 32 * 1024 * 1024;
330 std::env::var("REDDB_GRPC_MAX_MESSAGE_BYTES")
331 .ok()
332 .and_then(|raw| raw.trim().parse::<usize>().ok())
333 .filter(|value| *value > 0)
334 .unwrap_or(DEFAULT)
335 }
336
337 fn configured_service(runtime: GrpcRuntime) -> GrpcCatalogService {
338 use tonic::codec::CompressionEncoding;
342 let max_message = Self::max_message_bytes();
343 let service = RedDbServer::new(runtime.clone())
344 .max_decoding_message_size(max_message)
345 .max_encoding_message_size(max_message)
346 .accept_compressed(CompressionEncoding::Zstd)
347 .accept_compressed(CompressionEncoding::Gzip)
348 .send_compressed(CompressionEncoding::Zstd);
349 GrpcCatalogService::new(service, runtime)
350 }
351}
352
353#[derive(Clone)]
354struct GrpcRuntime {
355 runtime: RedDBRuntime,
356 auth_store: Arc<AuthStore>,
357 prepared_registry: Arc<PreparedRegistry>,
358 oauth_validator: Option<Arc<crate::auth::OAuthValidator>>,
362}
363
364#[derive(Clone)]
365struct ResolvedGrpcAuth {
366 result: AuthResult,
367 identity: Option<(crate::auth::UserId, Role)>,
368}
369
370impl GrpcRuntime {
371 fn admin_use_cases(&self) -> AdminUseCases<'_, RedDBRuntime> {
372 AdminUseCases::new(&self.runtime)
373 }
374
375 fn entity_use_cases(&self) -> EntityUseCases<'_, RedDBRuntime> {
376 EntityUseCases::new(&self.runtime)
377 }
378
379 fn graph_use_cases(&self) -> GraphUseCases<'_, RedDBRuntime> {
380 GraphUseCases::new(&self.runtime)
381 }
382
383 fn native_use_cases(&self) -> NativeUseCases<'_, RedDBRuntime> {
384 NativeUseCases::new(&self.runtime)
385 }
386}
387
388fn grpc_query_value_to_param_value(value: QueryValue) -> Result<ParamValue, Status> {
389 use proto::query_value::Kind;
390
391 match value
392 .kind
393 .ok_or_else(|| Status::invalid_argument("missing query param value"))?
394 {
395 Kind::NullValue(_) => Ok(ParamValue::Null),
396 Kind::BoolValue(value) => Ok(ParamValue::Bool(value)),
397 Kind::IntValue(value) => Ok(ParamValue::Int64(value)),
398 Kind::FloatValue(value) => Ok(ParamValue::Float64(value)),
399 Kind::TextValue(value) => Ok(ParamValue::Text(value)),
400 Kind::BytesValue(value) => Ok(ParamValue::Bytes(value)),
401 Kind::VectorValue(value) => Ok(ParamValue::Vector(value.values)),
402 Kind::JsonValue(value) => {
403 let parsed = json_from_str::<JsonValue>(&value)
404 .map_err(|e| Status::invalid_argument(format!("json param parse error: {e}")))?;
405 let encoded = json_to_string(&parsed)
406 .map_err(|e| Status::invalid_argument(format!("json param encode error: {e}")))?;
407 Ok(ParamValue::Json(encoded.into_bytes()))
408 }
409 Kind::TimestampValue(value) => Ok(ParamValue::Timestamp(value)),
410 Kind::UuidValue(value) => {
411 let bytes: [u8; 16] = value.try_into().map_err(|value: Vec<u8>| {
412 Status::invalid_argument(format!(
413 "uuid param must be 16 bytes, got {}",
414 value.len()
415 ))
416 })?;
417 Ok(ParamValue::Uuid(bytes))
418 }
419 }
420}
421
422struct GrpcIdentityGuard {
428 previous_identity: Option<(String, crate::auth::Role)>,
429 previous_principal: Option<crate::auth::UserId>,
430 previous_tenant: Option<String>,
431}
432
433impl GrpcIdentityGuard {
434 fn install(identity: Option<(crate::auth::UserId, crate::auth::Role)>) -> Self {
435 use crate::runtime::execution_context as ctx;
436 let previous_identity = ctx::current_auth_identity_for_audit();
437 let previous_principal = ctx::current_auth_principal();
438 let previous_tenant = ctx::current_tenant();
439 match identity {
440 Some((id, role)) => {
441 match &id.tenant {
442 Some(tenant) => ctx::set_current_tenant(tenant.clone()),
443 None => ctx::clear_current_tenant(),
444 }
445 ctx::set_current_auth_principal(id, role);
446 }
447 None => {
448 ctx::clear_current_auth_identity();
449 ctx::clear_current_tenant();
450 }
451 }
452 Self {
453 previous_identity,
454 previous_principal,
455 previous_tenant,
456 }
457 }
458}
459
460impl Drop for GrpcIdentityGuard {
461 fn drop(&mut self) {
462 use crate::runtime::execution_context as ctx;
463 match (
464 self.previous_principal.take(),
465 self.previous_identity.take(),
466 ) {
467 (Some(principal), Some((_, role))) => ctx::set_current_auth_principal(principal, role),
468 _ => ctx::clear_current_auth_identity(),
469 }
470 match self.previous_tenant.take() {
471 Some(tenant) => ctx::set_current_tenant(tenant),
472 None => ctx::clear_current_tenant(),
473 }
474 }
475}
476
477fn grpc_request_identity<T>(
480 request: &Request<T>,
481) -> Option<(crate::auth::UserId, crate::auth::Role)> {
482 request
483 .extensions()
484 .get::<catalog_dispatch::GrpcDispatchContext>()
485 .and_then(|context| context.identity.clone())
486}
487
488async fn grpc_spawn_blocking<F, R>(runtime: &RedDBRuntime, f: F) -> Result<R, Status>
489where
490 F: FnOnce() -> R + Send + 'static,
491 R: Send + 'static,
492{
493 use crate::transport::execution_admission::ExecutionAdmissionError;
494
495 let admission = runtime.execution_admission();
496 let permit =
497 match tokio::time::timeout(runtime.execution_queue_timeout(), admission.acquire()).await {
498 Ok(Ok(permit)) => permit,
499 Ok(Err(ExecutionAdmissionError::QueueFull)) => {
500 return Err(Status::resource_exhausted(
501 "execution_queue_full: engine execution queue is full",
502 ))
503 }
504 Ok(Err(ExecutionAdmissionError::Closed)) => {
505 return Err(Status::unavailable(
506 "execution_admission_closed: engine execution admission is unavailable",
507 ))
508 }
509 Err(_) => {
510 return Err(Status::deadline_exceeded(
511 "execution_queue_timeout: deadline expired while waiting to execute",
512 ))
513 }
514 };
515 let bundle = crate::runtime::execution_context::snapshot_bundle();
516 tokio::task::spawn_blocking(move || {
517 let _permit = permit;
518 crate::runtime::execution_context::with_snapshot_bundle(&bundle, f)
519 })
520 .await
521 .map_err(|error| Status::internal(format!("engine execution task failed: {error}")))
522}
523
524fn execute_grpc_query_request(
525 runtime: &RedDBRuntime,
526 prepared: &PreparedRegistry,
527 identity: Option<(crate::auth::UserId, crate::auth::Role)>,
528 query: String,
529 params: Vec<QueryValue>,
530 commit_policy: Option<crate::replication::CommitPolicy>,
531) -> Result<crate::runtime::OwnedQueryResult, Status> {
532 if query.trim().is_empty() {
533 return Err(Status::invalid_argument("query field cannot be empty"));
534 }
535 let _identity = GrpcIdentityGuard::install(identity);
536
537 let params = params
538 .into_iter()
539 .map(grpc_query_value_to_param_value)
540 .collect::<Result<Vec<_>, _>>()?;
541 let mut request = RuntimeQueryRequest::sql(query, params);
542 if let Some(commit_policy) = commit_policy {
543 request = request.with_commit_policy(commit_policy);
544 }
545 QueryRequestExecutor::new(runtime, prepared)
546 .execute(request)
547 .map_err(grpc_query_request_error)
548}
549
550fn grpc_commit_policy_from_metadata(
551 metadata: &MetadataMap,
552) -> Result<Option<crate::replication::CommitPolicy>, Status> {
553 let Some(value) = metadata.get("x-reddb-commit-policy") else {
554 return Ok(None);
555 };
556 let value = value
557 .to_str()
558 .map_err(|_| Status::invalid_argument("x-reddb-commit-policy must be ascii"))?;
559 crate::replication::CommitPolicy::parse_strict(value)
560 .map(Some)
561 .ok_or_else(|| Status::invalid_argument(format!("invalid commit policy '{value}'")))
562}
563
564fn grpc_json_bind_to_param_value(value: &str) -> Result<ParamValue, Status> {
565 let value = json_from_str::<JsonValue>(value)
566 .map_err(|error| Status::invalid_argument(format!("bind parse error: {error}")))?;
567 Ok(match value {
568 JsonValue::Null => ParamValue::Null,
569 JsonValue::Bool(value) => ParamValue::Bool(value),
570 JsonValue::Integer(value) => ParamValue::Int64(value),
571 JsonValue::Number(value) if value.fract() == 0.0 && value.abs() < i64::MAX as f64 => {
572 ParamValue::Int64(value as i64)
573 }
574 JsonValue::Number(value) => ParamValue::Float64(value),
575 JsonValue::String(value) => ParamValue::Text(value),
576 other => ParamValue::Text(json_to_string(&other).unwrap_or_default()),
577 })
578}
579
580fn grpc_query_request_error(error: crate::api::RedDBError) -> Status {
581 let message = error.to_string();
582 if message.contains("commit policy timed out") {
583 return Status::deadline_exceeded(message);
584 }
585 match error {
586 crate::api::RedDBError::Query(message) if message.contains("not found or expired") => {
587 Status::not_found(message)
588 }
589 crate::api::RedDBError::Query(message)
590 if message == "prepared_needs_replan" || message == "prepared statements disabled" =>
591 {
592 Status::failed_precondition(message)
593 }
594 crate::api::RedDBError::Query(message)
597 if message.contains("prepared bind failed")
598 || message.contains("weaker than resolved floor") =>
599 {
600 Status::invalid_argument(message)
601 }
602 crate::api::RedDBError::Validation { message, .. } => Status::invalid_argument(message),
603 other => to_status(other),
607 }
608}
609
610#[cfg(test)]
611mod grpc_query_value_tests {
612 use super::*;
613 use proto::query_value::Kind;
614
615 #[tokio::test]
616 async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
617 let runtime =
618 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
619 runtime.configure_execution_admission(1, 0, 100);
620 let admission = runtime.execution_admission();
621 let active = admission.acquire().await.expect("active slot");
622
623 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
624 assert_eq!(status.code(), tonic::Code::ResourceExhausted);
625 assert!(status.message().contains("execution_queue_full"));
626 drop(active);
627 }
628
629 #[tokio::test]
630 async fn grpc_execution_reports_queue_deadline_separately() {
631 let runtime =
632 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
633 runtime.configure_execution_admission(1, 1, 1);
634 let admission = runtime.execution_admission();
635 let active = admission.acquire().await.expect("active slot");
636
637 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
638 assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
639 assert!(status.message().contains("execution_queue_timeout"));
640 drop(active);
641 }
642
643 #[test]
644 fn grpc_query_value_maps_to_request_param_variants() {
645 let cases = vec![
646 (
647 QueryValue {
648 kind: Some(Kind::NullValue(proto::QueryNull {})),
649 },
650 ParamValue::Null,
651 ),
652 (
653 QueryValue {
654 kind: Some(Kind::BoolValue(true)),
655 },
656 ParamValue::Bool(true),
657 ),
658 (
659 QueryValue {
660 kind: Some(Kind::IntValue(42)),
661 },
662 ParamValue::Int64(42),
663 ),
664 (
665 QueryValue {
666 kind: Some(Kind::FloatValue(1.5)),
667 },
668 ParamValue::Float64(1.5),
669 ),
670 (
671 QueryValue {
672 kind: Some(Kind::BytesValue(vec![0, 1, 2])),
673 },
674 ParamValue::Bytes(vec![0, 1, 2]),
675 ),
676 (
677 QueryValue {
678 kind: Some(Kind::VectorValue(proto::QueryVector {
679 values: vec![0.25, 0.5],
680 })),
681 },
682 ParamValue::Vector(vec![0.25, 0.5]),
683 ),
684 (
685 QueryValue {
686 kind: Some(Kind::TimestampValue(1_779_999_000)),
687 },
688 ParamValue::Timestamp(1_779_999_000),
689 ),
690 (
691 QueryValue {
692 kind: Some(Kind::UuidValue(vec![0x11; 16])),
693 },
694 ParamValue::Uuid([0x11; 16]),
695 ),
696 ];
697
698 for (input, expected) in cases {
699 assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
700 }
701
702 assert_eq!(
703 grpc_query_value_to_param_value(QueryValue {
704 kind: Some(Kind::TextValue("alice".into())),
705 })
706 .unwrap(),
707 ParamValue::Text("alice".to_string())
708 );
709 assert_eq!(
710 grpc_query_value_to_param_value(QueryValue {
711 kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
712 })
713 .unwrap(),
714 ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
715 );
716 }
717
718 #[test]
719 fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
720 assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
721 assert!(grpc_query_value_to_param_value(QueryValue {
722 kind: Some(Kind::UuidValue(vec![0; 15])),
723 })
724 .is_err());
725 }
726
727 #[test]
728 fn grpc_query_rejects_empty_query_before_runtime_parse() {
729 let runtime =
730 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
731 let prepared = PreparedRegistry::new();
732 let err = execute_grpc_query_request(
733 &runtime,
734 &prepared,
735 None,
736 " ".to_string(),
737 Vec::new(),
738 None,
739 )
740 .expect_err("empty query should fail");
741
742 assert_eq!(err.code(), tonic::Code::InvalidArgument);
743 assert_eq!(err.message(), "query field cannot be empty");
744 }
745
746 #[test]
747 fn grpc_query_params_are_bound_before_execution() {
748 let runtime =
749 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
750 seed_grpc_param_table(&runtime);
751 let prepared = PreparedRegistry::new();
752
753 let result = execute_grpc_query_request(
754 &runtime,
755 &prepared,
756 None,
757 "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
758 grpc_param_values(),
759 None,
760 )
761 .expect("parameterized query");
762
763 assert_eq!(result.result.records.len(), 1);
764 }
765
766 #[test]
767 fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
768 let _env_lock = env_lock().lock().expect("env lock");
769 let _env = EnvGuard::set(&[
770 ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
771 ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
772 ("RED_COMMIT_FAIL_ON_TIMEOUT", "true"),
773 ]);
774 let data_path = temp_data_path("grpc_ack_n_timeout");
775 cleanup(&data_path);
776
777 let runtime = RedDBRuntime::with_options(
778 crate::api::RedDBOptions::persistent(&data_path)
779 .with_replication(crate::replication::ReplicationConfig::primary()),
780 )
781 .expect("runtime");
782 let prepared = PreparedRegistry::new();
783
784 let err = execute_grpc_query_request(
785 &runtime,
786 &prepared,
787 None,
788 "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
789 Vec::new(),
790 None,
791 )
792 .expect_err("ack_n without replica ack must fail closed");
793
794 assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
795 assert!(
796 err.message().contains("commit policy timed out")
797 && err.message().contains("RED_COMMIT_FAIL_ON_TIMEOUT"),
798 "error should identify commit policy timeout, got {err:?}"
799 );
800 assert!(
801 runtime.cdc_current_lsn() > 0,
802 "local mutation should advance CDC before gRPC response fails"
803 );
804
805 cleanup(&data_path);
806 }
807
808 #[tokio::test]
809 async fn grpc_query_rpc_binds_query_request_params() {
810 let runtime =
811 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
812 seed_grpc_param_table(&runtime);
813 let service = GrpcRuntime {
814 runtime,
815 auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
816 prepared_registry: Arc::new(PreparedRegistry::new()),
817 oauth_validator: None,
818 };
819
820 let reply = RedDb::query(
821 &service,
822 Request::new(QueryRequest {
823 query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
824 entity_types: Vec::new(),
825 capabilities: Vec::new(),
826 params: grpc_param_values(),
827 }),
828 )
829 .await
830 .expect("query rpc")
831 .into_inner();
832
833 assert_eq!(reply.record_count, 1);
834 assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
835 assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
836 }
837
838 #[tokio::test]
839 async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
840 let runtime =
841 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
842 let server = RedDBGrpcServer::new(runtime.clone());
843 let plain_listener = server.grpc_runtime();
844 let tls_listener = server.grpc_runtime();
845 let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
846
847 let prepared = RedDb::prepare_query(
848 &plain_listener,
849 Request::new(PrepareQueryRequest {
850 query: "SELECT 7 AS value".to_string(),
851 }),
852 )
853 .await
854 .expect("prepare on plaintext listener")
855 .into_inner();
856
857 let reply = RedDb::execute_prepared(
858 &tls_listener,
859 Request::new(ExecutePreparedRequest {
860 prepared_id: prepared.prepared_id,
861 bind_json: vec!["42".to_string()],
862 }),
863 )
864 .await
865 .expect("execute on TLS listener of the same connection")
866 .into_inner();
867 assert!(reply.result_json.contains("42"), "{}", reply.result_json);
868
869 let error = RedDb::execute_prepared(
870 &second_connection,
871 Request::new(ExecutePreparedRequest {
872 prepared_id: prepared.prepared_id,
873 bind_json: vec!["42".to_string()],
874 }),
875 )
876 .await
877 .expect_err("another connection must not resolve the prepared ID");
878 assert_eq!(error.code(), tonic::Code::NotFound);
879 }
880
881 #[tokio::test]
882 async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
883 let runtime =
884 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
885 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
886 let prepared = RedDb::prepare_query(
887 &service,
888 Request::new(PrepareQueryRequest {
889 query: "SELECT 7 AS value".to_string(),
890 }),
891 )
892 .await
893 .expect("prepare query")
894 .into_inner();
895
896 runtime
897 .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
898 .expect("execute DDL");
899
900 let error = RedDb::execute_prepared(
901 &service,
902 Request::new(ExecutePreparedRequest {
903 prepared_id: prepared.prepared_id,
904 bind_json: vec!["42".to_string()],
905 }),
906 )
907 .await
908 .expect_err("DDL must invalidate the gRPC prepared shape");
909 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
910 assert_eq!(error.message(), "prepared_needs_replan");
911 }
912
913 #[tokio::test]
914 async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
915 let runtime =
916 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
917 let service = RedDBGrpcServer::new(runtime).grpc_runtime();
918 let prepared = RedDb::prepare_query(
919 &service,
920 Request::new(PrepareQueryRequest {
921 query: "SELECT 7 AS value".to_string(),
922 }),
923 )
924 .await
925 .expect("prepare before kill switch")
926 .into_inner();
927
928 service.prepared_registry.disable();
929
930 let prepare_error = RedDb::prepare_query(
931 &service,
932 Request::new(PrepareQueryRequest {
933 query: "SELECT 1".to_string(),
934 }),
935 )
936 .await
937 .expect_err("kill switch must reject prepare");
938 assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
939 assert_eq!(prepare_error.message(), "prepared statements disabled");
940
941 let execute_error = RedDb::execute_prepared(
942 &service,
943 Request::new(ExecutePreparedRequest {
944 prepared_id: prepared.prepared_id,
945 bind_json: vec!["42".to_string()],
946 }),
947 )
948 .await
949 .expect_err("kill switch must reject execute");
950 assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
951 assert_eq!(execute_error.message(), "prepared statements disabled");
952 }
953
954 #[tokio::test]
955 async fn grpc_query_honors_per_request_commit_policy() {
956 let _env_lock = env_lock().lock().expect("env lock");
957 let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
958 let runtime =
959 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
960 runtime
961 .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
962 .expect("create table");
963 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
964 let mut request = Request::new(QueryRequest {
965 query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
966 entity_types: Vec::new(),
967 capabilities: Vec::new(),
968 params: Vec::new(),
969 });
970 request
971 .metadata_mut()
972 .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
973
974 let error = RedDb::query(&service, request)
975 .await
976 .expect_err("weaker per-request policy must fail before mutation");
977 assert_eq!(error.code(), tonic::Code::InvalidArgument);
978 assert!(error.message().contains("weaker than resolved floor"));
979
980 let rows = runtime
981 .execute_query("SELECT id FROM grpc_request_policy")
982 .expect("read rows");
983 assert!(rows.result.records.is_empty());
984 }
985
986 #[tokio::test]
987 async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
988 let runtime = RedDBRuntime::with_options(
989 crate::api::RedDBOptions::in_memory()
990 .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
991 )
992 .expect("runtime");
993 let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
994 enabled: true,
995 require_auth: true,
996 ..crate::auth::AuthConfig::default()
997 }));
998 let bootstrap = auth_store
999 .bootstrap("replica", "secret")
1000 .expect("bootstrap");
1001 let policy = crate::auth::policies::Policy::from_json_str(
1002 r#"{
1003 "id": "replication-stream",
1004 "version": 1,
1005 "statements": [{
1006 "effect": "allow",
1007 "actions": ["cluster:replication:stream"],
1008 "resources": ["cluster:replication"]
1009 }]
1010 }"#,
1011 )
1012 .expect("policy");
1013 auth_store.put_policy(policy).expect("install policy");
1014 auth_store
1015 .attach_policy(
1016 crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1017 "replication-stream",
1018 )
1019 .expect("attach policy");
1020 let service = GrpcRuntime {
1021 runtime,
1022 auth_store,
1023 prepared_registry: Arc::new(PreparedRegistry::new()),
1024 oauth_validator: None,
1025 };
1026
1027 let open = reddb_wire::replication::WalStreamOpen {
1028 since_lsn: 0,
1029 max_count: 1,
1030 replica_id: Some("replica-a".to_string()),
1031 term: 5,
1032 await_data: false,
1033 await_timeout_ms: 1,
1034 };
1035 let mut request = Request::new(JsonPayloadRequest {
1036 payload_json: String::from_utf8(open.encode_json()).expect("json"),
1037 });
1038 request.metadata_mut().insert(
1039 "authorization",
1040 format!("Bearer {}", bootstrap.api_key.key)
1041 .parse()
1042 .expect("metadata"),
1043 );
1044
1045 let err = RedDb::pull_wal_records(&service, request)
1046 .await
1047 .expect_err("stale term should be fenced");
1048
1049 assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1050 assert!(
1051 err.message().contains("stale")
1052 || err.message().contains("fenced")
1053 || err.message().contains("current term"),
1054 "unexpected stale-term error: {err:?}"
1055 );
1056 }
1057
1058 fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1059 runtime
1060 .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1061 .expect("create table");
1062 runtime
1063 .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1064 .expect("insert alice");
1065 runtime
1066 .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1067 .expect("insert bob");
1068 }
1069
1070 fn grpc_param_values() -> Vec<QueryValue> {
1071 vec![
1072 QueryValue {
1073 kind: Some(Kind::IntValue(1)),
1074 },
1075 QueryValue {
1076 kind: Some(Kind::TextValue("Alice".to_string())),
1077 },
1078 ]
1079 }
1080
1081 fn env_lock() -> &'static std::sync::Mutex<()> {
1082 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1083 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1084 }
1085
1086 struct EnvGuard {
1087 previous: Vec<(&'static str, Option<String>)>,
1088 }
1089
1090 impl EnvGuard {
1091 fn set(vars: &[(&'static str, &'static str)]) -> Self {
1092 let previous = vars
1093 .iter()
1094 .map(|(key, _)| (*key, std::env::var(key).ok()))
1095 .collect();
1096 for (key, value) in vars {
1097 std::env::set_var(key, value);
1098 }
1099 Self { previous }
1100 }
1101 }
1102
1103 impl Drop for EnvGuard {
1104 fn drop(&mut self) {
1105 for (key, value) in self.previous.iter().rev() {
1106 match value {
1107 Some(value) => std::env::set_var(key, value),
1108 None => std::env::remove_var(key),
1109 }
1110 }
1111 }
1112 }
1113
1114 fn temp_data_path(name: &str) -> std::path::PathBuf {
1115 let suffix = SystemTime::now()
1116 .duration_since(UNIX_EPOCH)
1117 .unwrap()
1118 .as_nanos();
1119 std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1120 }
1121
1122 fn cleanup(data_path: &std::path::Path) {
1123 let _ = std::fs::remove_file(data_path);
1124 let _ = std::fs::remove_file(
1125 crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1126 );
1127 let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1128 data_path,
1129 ));
1130 let _ = std::fs::remove_dir_all(
1131 crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1132 );
1133 reddb_file::cleanup_rebootstrap_artifacts(data_path);
1134 }
1135}
1136
1137#[cfg(test)]
1138mod grpc_ask_query_reply_tests {
1139 use super::*;
1140 use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1141 use reddb_rql::modes::QueryMode;
1142 use reddb_types::Value as SchemaValue;
1143
1144 fn ask_runtime_result() -> RuntimeQueryResult {
1145 let mut result = UnifiedResult::with_columns(vec![
1146 "answer".into(),
1147 "provider".into(),
1148 "model".into(),
1149 "mode".into(),
1150 "retry_count".into(),
1151 "prompt_tokens".into(),
1152 "completion_tokens".into(),
1153 "sources_flat".into(),
1154 "citations".into(),
1155 "validation".into(),
1156 ]);
1157 let mut record = UnifiedRecord::new();
1158 record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1159 record.set("provider", SchemaValue::text("openai"));
1160 record.set("model", SchemaValue::text("gpt-4o-mini"));
1161 record.set("mode", SchemaValue::text("strict"));
1162 record.set("retry_count", SchemaValue::Integer(0));
1163 record.set("prompt_tokens", SchemaValue::Integer(11));
1164 record.set("completion_tokens", SchemaValue::Integer(7));
1165 record.set(
1166 "sources_flat",
1167 SchemaValue::Json(
1168 br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1169 ),
1170 );
1171 record.set(
1172 "citations",
1173 SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1174 );
1175 record.set(
1176 "validation",
1177 SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1178 );
1179 result.push(record);
1180
1181 RuntimeQueryResult {
1182 query: "ASK 'why did deploy fail?'".to_string(),
1183 mode: QueryMode::Sql,
1184 statement: "ask",
1185 engine: "runtime-ai",
1186 result,
1187 affected_rows: 0,
1188 statement_type: "select",
1189 bookmark: None,
1190 notice: None,
1191 }
1192 }
1193
1194 #[test]
1195 fn query_reply_ask_result_json_uses_full_canonical_schema() {
1196 let reply = query_reply(ask_runtime_result(), &None, &None);
1197 let json: crate::json::Value =
1198 crate::json::from_str(&reply.result_json).expect("valid ask json");
1199
1200 assert_eq!(
1201 json.get("answer").and_then(crate::json::Value::as_str),
1202 Some("Deploy failed [^1].")
1203 );
1204 assert_eq!(
1205 json.get("cache_hit").and_then(crate::json::Value::as_bool),
1206 Some(false)
1207 );
1208 assert_eq!(
1209 json.get("cost_usd").and_then(crate::json::Value::as_f64),
1210 Some(0.0)
1211 );
1212 assert_eq!(
1213 json.get("mode").and_then(crate::json::Value::as_str),
1214 Some("strict")
1215 );
1216 assert_eq!(
1217 json.get("retry_count").and_then(crate::json::Value::as_u64),
1218 Some(0)
1219 );
1220 assert!(
1221 json.get("records").is_none(),
1222 "ASK must not be row-wrapped: {}",
1223 reply.result_json
1224 );
1225 assert!(
1226 json.get("sources_flat")
1227 .and_then(crate::json::Value::as_array)
1228 .is_some_and(|sources| sources.len() == 1
1229 && sources[0]
1230 .get("payload")
1231 .and_then(crate::json::Value::as_str)
1232 .is_some()),
1233 "sources_flat must be parsed with payload fallback: {}",
1234 reply.result_json
1235 );
1236 assert!(
1237 json.get("citations")
1238 .and_then(crate::json::Value::as_array)
1239 .is_some_and(|citations| citations.len() == 1),
1240 "citations must be parsed: {}",
1241 reply.result_json
1242 );
1243 assert_eq!(
1244 json.get("validation")
1245 .and_then(|v| v.get("ok"))
1246 .and_then(crate::json::Value::as_bool),
1247 Some(true)
1248 );
1249 }
1250
1251 #[test]
1252 fn query_reply_non_ask_answer_column_keeps_row_shape() {
1253 let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1254 let mut record = UnifiedRecord::new();
1255 record.set("answer", SchemaValue::text("plain select"));
1256 result.push(record);
1257
1258 let reply = query_reply(
1259 RuntimeQueryResult {
1260 query: "SELECT 'plain select' AS answer".to_string(),
1261 mode: QueryMode::Sql,
1262 statement: "select",
1263 engine: "runtime-sql",
1264 result,
1265 affected_rows: 0,
1266 statement_type: "select",
1267 bookmark: None,
1268 notice: None,
1269 },
1270 &None,
1271 &None,
1272 );
1273 let json: crate::json::Value =
1274 crate::json::from_str(&reply.result_json).expect("valid query json");
1275
1276 assert!(
1277 json.get("records").is_some(),
1278 "non-ASK must stay row-wrapped"
1279 );
1280 assert!(
1281 json.get("answer").is_none(),
1282 "non-ASK must not use ASK envelope"
1283 );
1284 }
1285
1286 #[test]
1287 fn query_reply_non_ask_json_column_preserves_object() {
1288 let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1289 let mut record = UnifiedRecord::new();
1290 record.set(
1291 "value",
1292 SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1293 );
1294 result.push(record);
1295
1296 let reply = query_reply(
1297 RuntimeQueryResult {
1298 query: "LIST KV proj AS JSON".to_string(),
1299 mode: QueryMode::Sql,
1300 statement: "kv_list_json",
1301 engine: "kv",
1302 result,
1303 affected_rows: 0,
1304 statement_type: "select",
1305 bookmark: None,
1306 notice: None,
1307 },
1308 &None,
1309 &None,
1310 );
1311 let json: crate::json::Value =
1312 crate::json::from_str(&reply.result_json).expect("valid query json");
1313 let value = json
1314 .get("records")
1315 .and_then(crate::json::Value::as_array)
1316 .and_then(|records| records.first())
1317 .and_then(|record| record.get("value"))
1318 .expect("value column");
1319
1320 assert_eq!(
1321 value.get("alpha").and_then(crate::json::Value::as_str),
1322 Some("A")
1323 );
1324 assert_eq!(
1325 value
1326 .get("nested")
1327 .and_then(|nested| nested.get("leaf"))
1328 .and_then(crate::json::Value::as_f64),
1329 Some(12.0)
1330 );
1331 }
1332}
1333
1334fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1337 use sha2::{Digest, Sha256};
1338 let cert_fp = {
1339 let mut h = Sha256::new();
1340 h.update(&tls.cert_pem);
1341 let digest = h.finalize();
1342 let mut buf = String::with_capacity(64);
1345 for b in digest.iter() {
1346 buf.push_str(&format!("{b:02x}"));
1347 }
1348 buf
1349 };
1350 tracing::info!(
1351 target: "reddb::security",
1352 transport = "grpc",
1353 cert_sha256 = %cert_fp,
1354 mtls = tls.client_ca_pem.is_some(),
1355 "gRPC TLS identity loaded"
1356 );
1357}
1358
1359include!("grpc/service_impl.rs");