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