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_tenant: Option<String>,
430}
431
432impl GrpcIdentityGuard {
433 fn install(identity: Option<(crate::auth::UserId, crate::auth::Role)>) -> Self {
434 use crate::runtime::execution_context as ctx;
435 let previous_identity = ctx::current_auth_identity_for_audit();
436 let previous_tenant = ctx::current_tenant();
437 match identity {
438 Some((id, role)) => {
439 match id.tenant {
440 Some(tenant) => ctx::set_current_tenant(tenant),
441 None => ctx::clear_current_tenant(),
442 }
443 ctx::set_current_auth_identity(id.username, role);
444 }
445 None => {
446 ctx::clear_current_auth_identity();
447 ctx::clear_current_tenant();
448 }
449 }
450 Self {
451 previous_identity,
452 previous_tenant,
453 }
454 }
455}
456
457impl Drop for GrpcIdentityGuard {
458 fn drop(&mut self) {
459 use crate::runtime::execution_context as ctx;
460 match self.previous_identity.take() {
461 Some((username, role)) => ctx::set_current_auth_identity(username, role),
462 None => ctx::clear_current_auth_identity(),
463 }
464 match self.previous_tenant.take() {
465 Some(tenant) => ctx::set_current_tenant(tenant),
466 None => ctx::clear_current_tenant(),
467 }
468 }
469}
470
471fn grpc_request_identity<T>(
474 request: &Request<T>,
475) -> Option<(crate::auth::UserId, crate::auth::Role)> {
476 request
477 .extensions()
478 .get::<catalog_dispatch::GrpcDispatchContext>()
479 .and_then(|context| context.identity.clone())
480}
481
482async fn grpc_spawn_blocking<F, R>(runtime: &RedDBRuntime, f: F) -> Result<R, Status>
483where
484 F: FnOnce() -> R + Send + 'static,
485 R: Send + 'static,
486{
487 use crate::transport::execution_admission::ExecutionAdmissionError;
488
489 let admission = runtime.execution_admission();
490 let permit =
491 match tokio::time::timeout(runtime.execution_queue_timeout(), admission.acquire()).await {
492 Ok(Ok(permit)) => permit,
493 Ok(Err(ExecutionAdmissionError::QueueFull)) => {
494 return Err(Status::resource_exhausted(
495 "execution_queue_full: engine execution queue is full",
496 ))
497 }
498 Ok(Err(ExecutionAdmissionError::Closed)) => {
499 return Err(Status::unavailable(
500 "execution_admission_closed: engine execution admission is unavailable",
501 ))
502 }
503 Err(_) => {
504 return Err(Status::deadline_exceeded(
505 "execution_queue_timeout: deadline expired while waiting to execute",
506 ))
507 }
508 };
509 let bundle = crate::runtime::execution_context::snapshot_bundle();
510 tokio::task::spawn_blocking(move || {
511 let _permit = permit;
512 crate::runtime::execution_context::with_snapshot_bundle(&bundle, f)
513 })
514 .await
515 .map_err(|error| Status::internal(format!("engine execution task failed: {error}")))
516}
517
518fn execute_grpc_query_request(
519 runtime: &RedDBRuntime,
520 prepared: &PreparedRegistry,
521 identity: Option<(crate::auth::UserId, crate::auth::Role)>,
522 query: String,
523 params: Vec<QueryValue>,
524 commit_policy: Option<crate::replication::CommitPolicy>,
525) -> Result<crate::runtime::OwnedQueryResult, Status> {
526 if query.trim().is_empty() {
527 return Err(Status::invalid_argument("query field cannot be empty"));
528 }
529 let _identity = GrpcIdentityGuard::install(identity);
530
531 let params = params
532 .into_iter()
533 .map(grpc_query_value_to_param_value)
534 .collect::<Result<Vec<_>, _>>()?;
535 let mut request = RuntimeQueryRequest::sql(query, params);
536 if let Some(commit_policy) = commit_policy {
537 request = request.with_commit_policy(commit_policy);
538 }
539 QueryRequestExecutor::new(runtime, prepared)
540 .execute(request)
541 .map_err(grpc_query_request_error)
542}
543
544fn grpc_commit_policy_from_metadata(
545 metadata: &MetadataMap,
546) -> Result<Option<crate::replication::CommitPolicy>, Status> {
547 let Some(value) = metadata.get("x-reddb-commit-policy") else {
548 return Ok(None);
549 };
550 let value = value
551 .to_str()
552 .map_err(|_| Status::invalid_argument("x-reddb-commit-policy must be ascii"))?;
553 crate::replication::CommitPolicy::parse_strict(value)
554 .map(Some)
555 .ok_or_else(|| Status::invalid_argument(format!("invalid commit policy '{value}'")))
556}
557
558fn grpc_json_bind_to_param_value(value: &str) -> Result<ParamValue, Status> {
559 let value = json_from_str::<JsonValue>(value)
560 .map_err(|error| Status::invalid_argument(format!("bind parse error: {error}")))?;
561 Ok(match value {
562 JsonValue::Null => ParamValue::Null,
563 JsonValue::Bool(value) => ParamValue::Bool(value),
564 JsonValue::Integer(value) => ParamValue::Int64(value),
565 JsonValue::Number(value) if value.fract() == 0.0 && value.abs() < i64::MAX as f64 => {
566 ParamValue::Int64(value as i64)
567 }
568 JsonValue::Number(value) => ParamValue::Float64(value),
569 JsonValue::String(value) => ParamValue::Text(value),
570 other => ParamValue::Text(json_to_string(&other).unwrap_or_default()),
571 })
572}
573
574fn grpc_query_request_error(error: crate::api::RedDBError) -> Status {
575 let message = error.to_string();
576 if message.contains("commit policy timed out") {
577 return Status::deadline_exceeded(message);
578 }
579 match error {
580 crate::api::RedDBError::Query(message) if message.contains("not found or expired") => {
581 Status::not_found(message)
582 }
583 crate::api::RedDBError::Query(message)
584 if message == "prepared_needs_replan" || message == "prepared statements disabled" =>
585 {
586 Status::failed_precondition(message)
587 }
588 crate::api::RedDBError::Query(message)
591 if message.contains("prepared bind failed")
592 || message.contains("weaker than resolved floor") =>
593 {
594 Status::invalid_argument(message)
595 }
596 crate::api::RedDBError::Validation { message, .. } => Status::invalid_argument(message),
597 other => to_status(other),
601 }
602}
603
604#[cfg(test)]
605mod grpc_query_value_tests {
606 use super::*;
607 use proto::query_value::Kind;
608
609 #[tokio::test]
610 async fn grpc_execution_refuses_when_shared_runtime_queue_is_full() {
611 let runtime =
612 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
613 runtime.configure_execution_admission(1, 0, 100);
614 let admission = runtime.execution_admission();
615 let active = admission.acquire().await.expect("active slot");
616
617 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
618 assert_eq!(status.code(), tonic::Code::ResourceExhausted);
619 assert!(status.message().contains("execution_queue_full"));
620 drop(active);
621 }
622
623 #[tokio::test]
624 async fn grpc_execution_reports_queue_deadline_separately() {
625 let runtime =
626 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
627 runtime.configure_execution_admission(1, 1, 1);
628 let admission = runtime.execution_admission();
629 let active = admission.acquire().await.expect("active slot");
630
631 let status = grpc_spawn_blocking(&runtime, || ()).await.unwrap_err();
632 assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
633 assert!(status.message().contains("execution_queue_timeout"));
634 drop(active);
635 }
636
637 #[test]
638 fn grpc_query_value_maps_to_request_param_variants() {
639 let cases = vec![
640 (
641 QueryValue {
642 kind: Some(Kind::NullValue(proto::QueryNull {})),
643 },
644 ParamValue::Null,
645 ),
646 (
647 QueryValue {
648 kind: Some(Kind::BoolValue(true)),
649 },
650 ParamValue::Bool(true),
651 ),
652 (
653 QueryValue {
654 kind: Some(Kind::IntValue(42)),
655 },
656 ParamValue::Int64(42),
657 ),
658 (
659 QueryValue {
660 kind: Some(Kind::FloatValue(1.5)),
661 },
662 ParamValue::Float64(1.5),
663 ),
664 (
665 QueryValue {
666 kind: Some(Kind::BytesValue(vec![0, 1, 2])),
667 },
668 ParamValue::Bytes(vec![0, 1, 2]),
669 ),
670 (
671 QueryValue {
672 kind: Some(Kind::VectorValue(proto::QueryVector {
673 values: vec![0.25, 0.5],
674 })),
675 },
676 ParamValue::Vector(vec![0.25, 0.5]),
677 ),
678 (
679 QueryValue {
680 kind: Some(Kind::TimestampValue(1_779_999_000)),
681 },
682 ParamValue::Timestamp(1_779_999_000),
683 ),
684 (
685 QueryValue {
686 kind: Some(Kind::UuidValue(vec![0x11; 16])),
687 },
688 ParamValue::Uuid([0x11; 16]),
689 ),
690 ];
691
692 for (input, expected) in cases {
693 assert_eq!(grpc_query_value_to_param_value(input).unwrap(), expected);
694 }
695
696 assert_eq!(
697 grpc_query_value_to_param_value(QueryValue {
698 kind: Some(Kind::TextValue("alice".into())),
699 })
700 .unwrap(),
701 ParamValue::Text("alice".to_string())
702 );
703 assert_eq!(
704 grpc_query_value_to_param_value(QueryValue {
705 kind: Some(Kind::JsonValue("{\"role\":\"admin\"}".into())),
706 })
707 .unwrap(),
708 ParamValue::Json(b"{\"role\":\"admin\"}".to_vec())
709 );
710 }
711
712 #[test]
713 fn grpc_query_value_rejects_missing_kind_and_bad_uuid() {
714 assert!(grpc_query_value_to_param_value(QueryValue { kind: None }).is_err());
715 assert!(grpc_query_value_to_param_value(QueryValue {
716 kind: Some(Kind::UuidValue(vec![0; 15])),
717 })
718 .is_err());
719 }
720
721 #[test]
722 fn grpc_query_rejects_empty_query_before_runtime_parse() {
723 let runtime =
724 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
725 let prepared = PreparedRegistry::new();
726 let err = execute_grpc_query_request(
727 &runtime,
728 &prepared,
729 None,
730 " ".to_string(),
731 Vec::new(),
732 None,
733 )
734 .expect_err("empty query should fail");
735
736 assert_eq!(err.code(), tonic::Code::InvalidArgument);
737 assert_eq!(err.message(), "query field cannot be empty");
738 }
739
740 #[test]
741 fn grpc_query_params_are_bound_before_execution() {
742 let runtime =
743 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
744 seed_grpc_param_table(&runtime);
745 let prepared = PreparedRegistry::new();
746
747 let result = execute_grpc_query_request(
748 &runtime,
749 &prepared,
750 None,
751 "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
752 grpc_param_values(),
753 None,
754 )
755 .expect("parameterized query");
756
757 assert_eq!(result.result.records.len(), 1);
758 }
759
760 #[test]
761 fn grpc_query_enforces_ack_n_commit_policy_fail_closed() {
762 let _env_lock = env_lock().lock().expect("env lock");
763 let _env = EnvGuard::set(&[
764 ("RED_PRIMARY_COMMIT_POLICY", "ack_n=1"),
765 ("RED_REPLICATION_ACK_TIMEOUT_MS", "20"),
766 ("RED_COMMIT_FAIL_ON_TIMEOUT", "true"),
767 ]);
768 let data_path = temp_data_path("grpc_ack_n_timeout");
769 cleanup(&data_path);
770
771 let runtime = RedDBRuntime::with_options(
772 crate::api::RedDBOptions::persistent(&data_path)
773 .with_replication(crate::replication::ReplicationConfig::primary()),
774 )
775 .expect("runtime");
776 let prepared = PreparedRegistry::new();
777
778 let err = execute_grpc_query_request(
779 &runtime,
780 &prepared,
781 None,
782 "INSERT INTO grpc_ack_items (id, name) VALUES (1, 'alpha')".to_string(),
783 Vec::new(),
784 None,
785 )
786 .expect_err("ack_n without replica ack must fail closed");
787
788 assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
789 assert!(
790 err.message().contains("commit policy timed out")
791 && err.message().contains("RED_COMMIT_FAIL_ON_TIMEOUT"),
792 "error should identify commit policy timeout, got {err:?}"
793 );
794 assert!(
795 runtime.cdc_current_lsn() > 0,
796 "local mutation should advance CDC before gRPC response fails"
797 );
798
799 cleanup(&data_path);
800 }
801
802 #[tokio::test]
803 async fn grpc_query_rpc_binds_query_request_params() {
804 let runtime =
805 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
806 seed_grpc_param_table(&runtime);
807 let service = GrpcRuntime {
808 runtime,
809 auth_store: Arc::new(AuthStore::new(crate::auth::AuthConfig::default())),
810 prepared_registry: Arc::new(PreparedRegistry::new()),
811 oauth_validator: None,
812 };
813
814 let reply = RedDb::query(
815 &service,
816 Request::new(QueryRequest {
817 query: "SELECT id, name FROM p WHERE id = $1 AND name = $2".to_string(),
818 entity_types: Vec::new(),
819 capabilities: Vec::new(),
820 params: grpc_param_values(),
821 }),
822 )
823 .await
824 .expect("query rpc")
825 .into_inner();
826
827 assert_eq!(reply.record_count, 1);
828 assert!(reply.result_json.contains("Alice"), "{}", reply.result_json);
829 assert!(!reply.result_json.contains("Bob"), "{}", reply.result_json);
830 }
831
832 #[tokio::test]
833 async fn grpc_prepared_id_is_shared_by_connection_listeners_only() {
834 let runtime =
835 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
836 let server = RedDBGrpcServer::new(runtime.clone());
837 let plain_listener = server.grpc_runtime();
838 let tls_listener = server.grpc_runtime();
839 let second_connection = RedDBGrpcServer::new(runtime).grpc_runtime();
840
841 let prepared = RedDb::prepare_query(
842 &plain_listener,
843 Request::new(PrepareQueryRequest {
844 query: "SELECT 7 AS value".to_string(),
845 }),
846 )
847 .await
848 .expect("prepare on plaintext listener")
849 .into_inner();
850
851 let reply = RedDb::execute_prepared(
852 &tls_listener,
853 Request::new(ExecutePreparedRequest {
854 prepared_id: prepared.prepared_id,
855 bind_json: vec!["42".to_string()],
856 }),
857 )
858 .await
859 .expect("execute on TLS listener of the same connection")
860 .into_inner();
861 assert!(reply.result_json.contains("42"), "{}", reply.result_json);
862
863 let error = RedDb::execute_prepared(
864 &second_connection,
865 Request::new(ExecutePreparedRequest {
866 prepared_id: prepared.prepared_id,
867 bind_json: vec!["42".to_string()],
868 }),
869 )
870 .await
871 .expect_err("another connection must not resolve the prepared ID");
872 assert_eq!(error.code(), tonic::Code::NotFound);
873 }
874
875 #[tokio::test]
876 async fn grpc_execute_prepared_rejects_a_stale_ddl_epoch() {
877 let runtime =
878 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
879 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
880 let prepared = RedDb::prepare_query(
881 &service,
882 Request::new(PrepareQueryRequest {
883 query: "SELECT 7 AS value".to_string(),
884 }),
885 )
886 .await
887 .expect("prepare query")
888 .into_inner();
889
890 runtime
891 .execute_query("CREATE TABLE grpc_prepared_epoch (id INTEGER)")
892 .expect("execute DDL");
893
894 let error = RedDb::execute_prepared(
895 &service,
896 Request::new(ExecutePreparedRequest {
897 prepared_id: prepared.prepared_id,
898 bind_json: vec!["42".to_string()],
899 }),
900 )
901 .await
902 .expect_err("DDL must invalidate the gRPC prepared shape");
903 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
904 assert_eq!(error.message(), "prepared_needs_replan");
905 }
906
907 #[tokio::test]
908 async fn grpc_prepared_kill_switch_rejects_prepare_and_execute() {
909 let runtime =
910 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
911 let service = RedDBGrpcServer::new(runtime).grpc_runtime();
912 let prepared = RedDb::prepare_query(
913 &service,
914 Request::new(PrepareQueryRequest {
915 query: "SELECT 7 AS value".to_string(),
916 }),
917 )
918 .await
919 .expect("prepare before kill switch")
920 .into_inner();
921
922 service.prepared_registry.disable();
923
924 let prepare_error = RedDb::prepare_query(
925 &service,
926 Request::new(PrepareQueryRequest {
927 query: "SELECT 1".to_string(),
928 }),
929 )
930 .await
931 .expect_err("kill switch must reject prepare");
932 assert_eq!(prepare_error.code(), tonic::Code::FailedPrecondition);
933 assert_eq!(prepare_error.message(), "prepared statements disabled");
934
935 let execute_error = RedDb::execute_prepared(
936 &service,
937 Request::new(ExecutePreparedRequest {
938 prepared_id: prepared.prepared_id,
939 bind_json: vec!["42".to_string()],
940 }),
941 )
942 .await
943 .expect_err("kill switch must reject execute");
944 assert_eq!(execute_error.code(), tonic::Code::FailedPrecondition);
945 assert_eq!(execute_error.message(), "prepared statements disabled");
946 }
947
948 #[tokio::test]
949 async fn grpc_query_honors_per_request_commit_policy() {
950 let _env_lock = env_lock().lock().expect("env lock");
951 let _env = EnvGuard::set(&[("RED_PRIMARY_COMMIT_POLICY", "ack_n=1")]);
952 let runtime =
953 RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
954 runtime
955 .execute_query("CREATE TABLE grpc_request_policy (id INTEGER)")
956 .expect("create table");
957 let service = RedDBGrpcServer::new(runtime.clone()).grpc_runtime();
958 let mut request = Request::new(QueryRequest {
959 query: "INSERT INTO grpc_request_policy (id) VALUES (1)".to_string(),
960 entity_types: Vec::new(),
961 capabilities: Vec::new(),
962 params: Vec::new(),
963 });
964 request
965 .metadata_mut()
966 .insert("x-reddb-commit-policy", "local".parse().expect("metadata"));
967
968 let error = RedDb::query(&service, request)
969 .await
970 .expect_err("weaker per-request policy must fail before mutation");
971 assert_eq!(error.code(), tonic::Code::InvalidArgument);
972 assert!(error.message().contains("weaker than resolved floor"));
973
974 let rows = runtime
975 .execute_query("SELECT id FROM grpc_request_policy")
976 .expect("read rows");
977 assert!(rows.result.records.is_empty());
978 }
979
980 #[tokio::test]
981 async fn pull_wal_records_rejects_stale_term_on_grpc_path() {
982 let runtime = RedDBRuntime::with_options(
983 crate::api::RedDBOptions::in_memory()
984 .with_replication(crate::replication::ReplicationConfig::primary().with_term(6)),
985 )
986 .expect("runtime");
987 let auth_store = Arc::new(AuthStore::new(crate::auth::AuthConfig {
988 enabled: true,
989 require_auth: true,
990 ..crate::auth::AuthConfig::default()
991 }));
992 let bootstrap = auth_store
993 .bootstrap("replica", "secret")
994 .expect("bootstrap");
995 let policy = crate::auth::policies::Policy::from_json_str(
996 r#"{
997 "id": "replication-stream",
998 "version": 1,
999 "statements": [{
1000 "effect": "allow",
1001 "actions": ["cluster:replication:stream"],
1002 "resources": ["cluster:replication"]
1003 }]
1004 }"#,
1005 )
1006 .expect("policy");
1007 auth_store.put_policy(policy).expect("install policy");
1008 auth_store
1009 .attach_policy(
1010 crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform("replica")),
1011 "replication-stream",
1012 )
1013 .expect("attach policy");
1014 let service = GrpcRuntime {
1015 runtime,
1016 auth_store,
1017 prepared_registry: Arc::new(PreparedRegistry::new()),
1018 oauth_validator: None,
1019 };
1020
1021 let open = reddb_wire::replication::WalStreamOpen {
1022 since_lsn: 0,
1023 max_count: 1,
1024 replica_id: Some("replica-a".to_string()),
1025 term: 5,
1026 await_data: false,
1027 await_timeout_ms: 1,
1028 };
1029 let mut request = Request::new(JsonPayloadRequest {
1030 payload_json: String::from_utf8(open.encode_json()).expect("json"),
1031 });
1032 request.metadata_mut().insert(
1033 "authorization",
1034 format!("Bearer {}", bootstrap.api_key.key)
1035 .parse()
1036 .expect("metadata"),
1037 );
1038
1039 let err = RedDb::pull_wal_records(&service, request)
1040 .await
1041 .expect_err("stale term should be fenced");
1042
1043 assert_eq!(err.code(), tonic::Code::FailedPrecondition);
1044 assert!(
1045 err.message().contains("stale")
1046 || err.message().contains("fenced")
1047 || err.message().contains("current term"),
1048 "unexpected stale-term error: {err:?}"
1049 );
1050 }
1051
1052 fn seed_grpc_param_table(runtime: &RedDBRuntime) {
1053 runtime
1054 .execute_query("CREATE TABLE p (id INTEGER, name TEXT)")
1055 .expect("create table");
1056 runtime
1057 .execute_query("INSERT INTO p (id, name) VALUES (1, 'Alice')")
1058 .expect("insert alice");
1059 runtime
1060 .execute_query("INSERT INTO p (id, name) VALUES (2, 'Bob')")
1061 .expect("insert bob");
1062 }
1063
1064 fn grpc_param_values() -> Vec<QueryValue> {
1065 vec![
1066 QueryValue {
1067 kind: Some(Kind::IntValue(1)),
1068 },
1069 QueryValue {
1070 kind: Some(Kind::TextValue("Alice".to_string())),
1071 },
1072 ]
1073 }
1074
1075 fn env_lock() -> &'static std::sync::Mutex<()> {
1076 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1077 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1078 }
1079
1080 struct EnvGuard {
1081 previous: Vec<(&'static str, Option<String>)>,
1082 }
1083
1084 impl EnvGuard {
1085 fn set(vars: &[(&'static str, &'static str)]) -> Self {
1086 let previous = vars
1087 .iter()
1088 .map(|(key, _)| (*key, std::env::var(key).ok()))
1089 .collect();
1090 for (key, value) in vars {
1091 std::env::set_var(key, value);
1092 }
1093 Self { previous }
1094 }
1095 }
1096
1097 impl Drop for EnvGuard {
1098 fn drop(&mut self) {
1099 for (key, value) in self.previous.iter().rev() {
1100 match value {
1101 Some(value) => std::env::set_var(key, value),
1102 None => std::env::remove_var(key),
1103 }
1104 }
1105 }
1106 }
1107
1108 fn temp_data_path(name: &str) -> std::path::PathBuf {
1109 let suffix = SystemTime::now()
1110 .duration_since(UNIX_EPOCH)
1111 .unwrap()
1112 .as_nanos();
1113 std::env::temp_dir().join(format!("reddb_{name}_{suffix}.rdb"))
1114 }
1115
1116 fn cleanup(data_path: &std::path::Path) {
1117 let _ = std::fs::remove_file(data_path);
1118 let _ = std::fs::remove_file(
1119 crate::replication::primary::PrimaryReplication::slot_path_for(data_path),
1120 );
1121 let _ = std::fs::remove_file(crate::replication::primary::LogicalWalSpool::path_for(
1122 data_path,
1123 ));
1124 let _ = std::fs::remove_dir_all(
1125 crate::replication::primary::PrimaryReplication::primary_replica_root_for(data_path),
1126 );
1127 reddb_file::cleanup_rebootstrap_artifacts(data_path);
1128 }
1129}
1130
1131#[cfg(test)]
1132mod grpc_ask_query_reply_tests {
1133 use super::*;
1134 use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
1135 use reddb_rql::modes::QueryMode;
1136 use reddb_types::Value as SchemaValue;
1137
1138 fn ask_runtime_result() -> RuntimeQueryResult {
1139 let mut result = UnifiedResult::with_columns(vec![
1140 "answer".into(),
1141 "provider".into(),
1142 "model".into(),
1143 "mode".into(),
1144 "retry_count".into(),
1145 "prompt_tokens".into(),
1146 "completion_tokens".into(),
1147 "sources_flat".into(),
1148 "citations".into(),
1149 "validation".into(),
1150 ]);
1151 let mut record = UnifiedRecord::new();
1152 record.set("answer", SchemaValue::text("Deploy failed [^1]."));
1153 record.set("provider", SchemaValue::text("openai"));
1154 record.set("model", SchemaValue::text("gpt-4o-mini"));
1155 record.set("mode", SchemaValue::text("strict"));
1156 record.set("retry_count", SchemaValue::Integer(0));
1157 record.set("prompt_tokens", SchemaValue::Integer(11));
1158 record.set("completion_tokens", SchemaValue::Integer(7));
1159 record.set(
1160 "sources_flat",
1161 SchemaValue::Json(
1162 br#"[{"urn":"urn:reddb:row:deployments:1","kind":"row","collection":"deployments","id":"1"}]"#.to_vec(),
1163 ),
1164 );
1165 record.set(
1166 "citations",
1167 SchemaValue::Json(br#"[{"marker":1,"urn":"urn:reddb:row:deployments:1"}]"#.to_vec()),
1168 );
1169 record.set(
1170 "validation",
1171 SchemaValue::Json(br#"{"ok":true,"warnings":[],"errors":[]}"#.to_vec()),
1172 );
1173 result.push(record);
1174
1175 RuntimeQueryResult {
1176 query: "ASK 'why did deploy fail?'".to_string(),
1177 mode: QueryMode::Sql,
1178 statement: "ask",
1179 engine: "runtime-ai",
1180 result,
1181 affected_rows: 0,
1182 statement_type: "select",
1183 bookmark: None,
1184 notice: None,
1185 }
1186 }
1187
1188 #[test]
1189 fn query_reply_ask_result_json_uses_full_canonical_schema() {
1190 let reply = query_reply(ask_runtime_result(), &None, &None);
1191 let json: crate::json::Value =
1192 crate::json::from_str(&reply.result_json).expect("valid ask json");
1193
1194 assert_eq!(
1195 json.get("answer").and_then(crate::json::Value::as_str),
1196 Some("Deploy failed [^1].")
1197 );
1198 assert_eq!(
1199 json.get("cache_hit").and_then(crate::json::Value::as_bool),
1200 Some(false)
1201 );
1202 assert_eq!(
1203 json.get("cost_usd").and_then(crate::json::Value::as_f64),
1204 Some(0.0)
1205 );
1206 assert_eq!(
1207 json.get("mode").and_then(crate::json::Value::as_str),
1208 Some("strict")
1209 );
1210 assert_eq!(
1211 json.get("retry_count").and_then(crate::json::Value::as_u64),
1212 Some(0)
1213 );
1214 assert!(
1215 json.get("records").is_none(),
1216 "ASK must not be row-wrapped: {}",
1217 reply.result_json
1218 );
1219 assert!(
1220 json.get("sources_flat")
1221 .and_then(crate::json::Value::as_array)
1222 .is_some_and(|sources| sources.len() == 1
1223 && sources[0]
1224 .get("payload")
1225 .and_then(crate::json::Value::as_str)
1226 .is_some()),
1227 "sources_flat must be parsed with payload fallback: {}",
1228 reply.result_json
1229 );
1230 assert!(
1231 json.get("citations")
1232 .and_then(crate::json::Value::as_array)
1233 .is_some_and(|citations| citations.len() == 1),
1234 "citations must be parsed: {}",
1235 reply.result_json
1236 );
1237 assert_eq!(
1238 json.get("validation")
1239 .and_then(|v| v.get("ok"))
1240 .and_then(crate::json::Value::as_bool),
1241 Some(true)
1242 );
1243 }
1244
1245 #[test]
1246 fn query_reply_non_ask_answer_column_keeps_row_shape() {
1247 let mut result = UnifiedResult::with_columns(vec!["answer".into()]);
1248 let mut record = UnifiedRecord::new();
1249 record.set("answer", SchemaValue::text("plain select"));
1250 result.push(record);
1251
1252 let reply = query_reply(
1253 RuntimeQueryResult {
1254 query: "SELECT 'plain select' AS answer".to_string(),
1255 mode: QueryMode::Sql,
1256 statement: "select",
1257 engine: "runtime-sql",
1258 result,
1259 affected_rows: 0,
1260 statement_type: "select",
1261 bookmark: None,
1262 notice: None,
1263 },
1264 &None,
1265 &None,
1266 );
1267 let json: crate::json::Value =
1268 crate::json::from_str(&reply.result_json).expect("valid query json");
1269
1270 assert!(
1271 json.get("records").is_some(),
1272 "non-ASK must stay row-wrapped"
1273 );
1274 assert!(
1275 json.get("answer").is_none(),
1276 "non-ASK must not use ASK envelope"
1277 );
1278 }
1279
1280 #[test]
1281 fn query_reply_non_ask_json_column_preserves_object() {
1282 let mut result = UnifiedResult::with_columns(vec!["value".into()]);
1283 let mut record = UnifiedRecord::new();
1284 record.set(
1285 "value",
1286 SchemaValue::Json(br#"{"alpha":"A","nested":{"leaf":12}}"#.to_vec()),
1287 );
1288 result.push(record);
1289
1290 let reply = query_reply(
1291 RuntimeQueryResult {
1292 query: "LIST KV proj AS JSON".to_string(),
1293 mode: QueryMode::Sql,
1294 statement: "kv_list_json",
1295 engine: "kv",
1296 result,
1297 affected_rows: 0,
1298 statement_type: "select",
1299 bookmark: None,
1300 notice: None,
1301 },
1302 &None,
1303 &None,
1304 );
1305 let json: crate::json::Value =
1306 crate::json::from_str(&reply.result_json).expect("valid query json");
1307 let value = json
1308 .get("records")
1309 .and_then(crate::json::Value::as_array)
1310 .and_then(|records| records.first())
1311 .and_then(|record| record.get("value"))
1312 .expect("value column");
1313
1314 assert_eq!(
1315 value.get("alpha").and_then(crate::json::Value::as_str),
1316 Some("A")
1317 );
1318 assert_eq!(
1319 value
1320 .get("nested")
1321 .and_then(|nested| nested.get("leaf"))
1322 .and_then(crate::json::Value::as_f64),
1323 Some(12.0)
1324 );
1325 }
1326}
1327
1328fn log_grpc_tls_identity(tls: &GrpcTlsOptions) {
1331 use sha2::{Digest, Sha256};
1332 let cert_fp = {
1333 let mut h = Sha256::new();
1334 h.update(&tls.cert_pem);
1335 let digest = h.finalize();
1336 let mut buf = String::with_capacity(64);
1339 for b in digest.iter() {
1340 buf.push_str(&format!("{b:02x}"));
1341 }
1342 buf
1343 };
1344 tracing::info!(
1345 target: "reddb::security",
1346 transport = "grpc",
1347 cert_sha256 = %cert_fp,
1348 mtls = tls.client_ca_pem.is_some(),
1349 "gRPC TLS identity loaded"
1350 );
1351}
1352
1353include!("grpc/service_impl.rs");