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