1use serde::{Deserialize, Serialize};
8use spicedb_embedded_sys::{dispose, start};
9use spicedb_grpc_tonic::v1::{
10 RelationshipUpdate, WriteRelationshipsRequest, WriteSchemaRequest,
11 relationship_update::Operation,
12};
13
14use crate::SpiceDBError;
15
16#[derive(Debug, Deserialize)]
18struct CResponse {
19 success: bool,
20 error: Option<String>,
21 data: Option<serde_json::Value>,
22}
23
24#[derive(Debug, Default, Clone, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub struct StartOptions {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub datastore: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub datastore_uri: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub spanner_credentials_file: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub spanner_emulator_host: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub mysql_table_prefix: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub metrics_enabled: Option<bool>,
46}
47
48fn parse_json_response(response_str: &str) -> Result<serde_json::Value, SpiceDBError> {
50 let response: CResponse = serde_json::from_str(response_str)
51 .map_err(|e| SpiceDBError::Protocol(format!("invalid JSON: {e} (raw: {response_str})")))?;
52
53 if response.success {
54 Ok(response.data.unwrap_or(serde_json::Value::Null))
55 } else {
56 Err(SpiceDBError::SpiceDB(
57 response.error.unwrap_or_else(|| "unknown error".into()),
58 ))
59 }
60}
61
62use spicedb_embedded_sys::memory_transport;
63use spicedb_grpc_tonic::v1::{
64 CheckBulkPermissionsRequest, CheckBulkPermissionsResponse, CheckPermissionRequest,
65 CheckPermissionResponse, DeleteRelationshipsRequest, DeleteRelationshipsResponse,
66 ExpandPermissionTreeRequest, ExpandPermissionTreeResponse, ReadSchemaRequest,
67 ReadSchemaResponse, WriteRelationshipsResponse, WriteSchemaResponse,
68};
69
70pub struct EmbeddedSpiceDB {
74 handle: u64,
75 streaming_address: String,
77 streaming_transport: String,
79}
80
81unsafe impl Send for EmbeddedSpiceDB {}
82unsafe impl Sync for EmbeddedSpiceDB {}
83
84impl EmbeddedSpiceDB {
85 pub fn start(options: Option<&StartOptions>) -> Result<Self, SpiceDBError> {
94 let opts = options.cloned().unwrap_or_default();
95 let json = serde_json::to_string(&opts)
96 .map_err(|e| SpiceDBError::Protocol(format!("serialize options: {e}")))?;
97 let response_str = start(Some(&json)).map_err(SpiceDBError::Runtime)?;
98 let data = parse_json_response(&response_str)?;
99 let handle = data
100 .get("handle")
101 .and_then(serde_json::Value::as_u64)
102 .ok_or_else(|| SpiceDBError::Protocol("missing handle in start response".into()))?;
103 let streaming_address = data
104 .get("streaming_address")
105 .and_then(serde_json::Value::as_str)
106 .map(String::from)
107 .ok_or_else(|| {
108 SpiceDBError::Protocol("missing streaming_address in start response".into())
109 })?;
110 let streaming_transport = data
111 .get("streaming_transport")
112 .and_then(serde_json::Value::as_str)
113 .map(String::from)
114 .ok_or_else(|| {
115 SpiceDBError::Protocol("missing streaming_transport in start response".into())
116 })?;
117
118 Ok(Self {
119 handle,
120 streaming_address,
121 streaming_transport,
122 })
123 }
124
125 pub fn start_with_schema(
133 schema: &str,
134 relationships: &[spicedb_grpc_tonic::v1::Relationship],
135 options: Option<&StartOptions>,
136 ) -> Result<Self, SpiceDBError> {
137 let db = Self::start(options)?;
138
139 if !schema.is_empty() {
140 memory_transport::write_schema(
141 db.handle,
142 &WriteSchemaRequest {
143 schema: schema.to_string(),
144 },
145 )
146 .map_err(|e| SpiceDBError::SpiceDB(e.0))?;
147 }
148
149 if !relationships.is_empty() {
150 let updates: Vec<RelationshipUpdate> = relationships
151 .iter()
152 .map(|r| RelationshipUpdate {
153 operation: Operation::Touch as i32,
154 relationship: Some(r.clone()),
155 })
156 .collect();
157 memory_transport::write_relationships(
158 db.handle,
159 &WriteRelationshipsRequest {
160 updates,
161 optional_preconditions: vec![],
162 optional_transaction_metadata: None,
163 },
164 )
165 .map_err(|e| SpiceDBError::SpiceDB(e.0))?;
166 }
167
168 Ok(db)
169 }
170
171 #[deprecated(since = "0.6.0", note = "renamed to start_with_schema")]
177 pub fn new(
178 schema: &str,
179 relationships: &[spicedb_grpc_tonic::v1::Relationship],
180 options: Option<&StartOptions>,
181 ) -> Result<Self, SpiceDBError> {
182 Self::start_with_schema(schema, relationships, options)
183 }
184
185 #[must_use]
187 pub const fn permissions(&self) -> MemoryPermissionsClient {
188 MemoryPermissionsClient {
189 handle: self.handle,
190 }
191 }
192
193 #[must_use]
195 pub const fn schema(&self) -> MemorySchemaClient {
196 MemorySchemaClient {
197 handle: self.handle,
198 }
199 }
200
201 #[must_use]
203 pub const fn handle(&self) -> u64 {
204 self.handle
205 }
206
207 #[must_use]
210 pub fn streaming_address(&self) -> &str {
211 &self.streaming_address
212 }
213
214 #[must_use]
216 pub fn streaming_transport(&self) -> &str {
217 &self.streaming_transport
218 }
219}
220
221impl Drop for EmbeddedSpiceDB {
222 fn drop(&mut self) {
223 let _ = dispose(self.handle);
224 }
225}
226
227pub struct MemoryPermissionsClient {
229 handle: u64,
230}
231
232impl MemoryPermissionsClient {
233 pub fn check_permission(
239 &self,
240 request: &CheckPermissionRequest,
241 ) -> Result<CheckPermissionResponse, SpiceDBError> {
242 memory_transport::check_permission(self.handle, request)
243 .map_err(|e| SpiceDBError::SpiceDB(e.0))
244 }
245
246 pub fn write_relationships(
252 &self,
253 request: &WriteRelationshipsRequest,
254 ) -> Result<WriteRelationshipsResponse, SpiceDBError> {
255 memory_transport::write_relationships(self.handle, request)
256 .map_err(|e| SpiceDBError::SpiceDB(e.0))
257 }
258
259 pub fn delete_relationships(
265 &self,
266 request: &DeleteRelationshipsRequest,
267 ) -> Result<DeleteRelationshipsResponse, SpiceDBError> {
268 memory_transport::delete_relationships(self.handle, request)
269 .map_err(|e| SpiceDBError::SpiceDB(e.0))
270 }
271
272 pub fn check_bulk_permissions(
278 &self,
279 request: &CheckBulkPermissionsRequest,
280 ) -> Result<CheckBulkPermissionsResponse, SpiceDBError> {
281 memory_transport::check_bulk_permissions(self.handle, request)
282 .map_err(|e| SpiceDBError::SpiceDB(e.0))
283 }
284
285 pub fn expand_permission_tree(
291 &self,
292 request: &ExpandPermissionTreeRequest,
293 ) -> Result<ExpandPermissionTreeResponse, SpiceDBError> {
294 memory_transport::expand_permission_tree(self.handle, request)
295 .map_err(|e| SpiceDBError::SpiceDB(e.0))
296 }
297}
298
299pub struct MemorySchemaClient {
301 handle: u64,
302}
303
304impl MemorySchemaClient {
305 pub fn read_schema(
311 &self,
312 request: &ReadSchemaRequest,
313 ) -> Result<ReadSchemaResponse, SpiceDBError> {
314 memory_transport::read_schema(self.handle, request).map_err(|e| SpiceDBError::SpiceDB(e.0))
315 }
316
317 pub fn write_schema(
323 &self,
324 request: &WriteSchemaRequest,
325 ) -> Result<WriteSchemaResponse, SpiceDBError> {
326 memory_transport::write_schema(self.handle, request).map_err(|e| SpiceDBError::SpiceDB(e.0))
327 }
328}
329
330#[cfg(test)]
331mod tests {
332
333 use std::time::Duration;
334
335 use spicedb_grpc_tonic::v1::{
336 CheckPermissionRequest, Consistency, ObjectReference, ReadRelationshipsRequest,
337 Relationship, RelationshipFilter, RelationshipUpdate, SubjectReference, WatchKind,
338 WatchRequest, WriteRelationshipsRequest, relationship_update::Operation,
339 watch_service_client::WatchServiceClient,
340 };
341 use tokio::time::timeout;
342 use tokio_stream::StreamExt;
343 use tonic::transport::{Channel, Endpoint};
344
345 use super::*;
346 use crate::v1::check_permission_response::Permissionship;
347
348 #[allow(unused_variables)] async fn connect_streaming(
351 addr: &str,
352 transport: &str,
353 ) -> Result<Channel, Box<dyn std::error::Error + Send + Sync>> {
354 #[cfg(unix)]
355 {
356 if transport == "unix" {
357 let path = addr.to_string();
358 Endpoint::try_from("http://[::]:50051")?
359 .connect_with_connector(tower::service_fn(move |_: tonic::transport::Uri| {
360 let path = path.clone();
361 async move {
362 let stream = tokio::net::UnixStream::connect(&path).await?;
363 Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream))
364 }
365 }))
366 .await
367 .map_err(Into::into)
368 } else {
369 Endpoint::from_shared(format!("http://{addr}"))?
370 .connect()
371 .await
372 .map_err(Into::into)
373 }
374 }
375 #[cfg(windows)]
376 {
377 Endpoint::from_shared(format!("http://{addr}"))?
378 .connect()
379 .await
380 .map_err(Into::into)
381 }
382 }
383
384 const TEST_SCHEMA: &str = r"
385definition user {}
386
387definition document {
388 relation reader: user
389 relation writer: user
390
391 permission read = reader + writer
392 permission write = writer
393}
394";
395
396 fn rel(resource: &str, relation: &str, subject: &str) -> Relationship {
397 let (res_type, res_id) = resource.split_once(':').unwrap();
398 let (sub_type, sub_id) = subject.split_once(':').unwrap();
399 Relationship {
400 resource: Some(ObjectReference {
401 object_type: res_type.into(),
402 object_id: res_id.into(),
403 }),
404 relation: relation.into(),
405 subject: Some(SubjectReference {
406 object: Some(ObjectReference {
407 object_type: sub_type.into(),
408 object_id: sub_id.into(),
409 }),
410 optional_relation: String::new(),
411 }),
412 optional_caveat: None,
413 optional_expires_at: None,
414 }
415 }
416
417 fn fully_consistent() -> Consistency {
418 Consistency {
419 requirement: Some(crate::v1::consistency::Requirement::FullyConsistent(true)),
420 }
421 }
422
423 fn check_req(resource: &str, permission: &str, subject: &str) -> CheckPermissionRequest {
424 let (res_type, res_id) = resource.split_once(':').unwrap();
425 let (sub_type, sub_id) = subject.split_once(':').unwrap();
426 CheckPermissionRequest {
427 consistency: Some(fully_consistent()),
428 resource: Some(ObjectReference {
429 object_type: res_type.into(),
430 object_id: res_id.into(),
431 }),
432 permission: permission.into(),
433 subject: Some(SubjectReference {
434 object: Some(ObjectReference {
435 object_type: sub_type.into(),
436 object_id: sub_id.into(),
437 }),
438 optional_relation: String::new(),
439 }),
440 context: None,
441 with_tracing: false,
442 }
443 }
444
445 #[test]
447 fn test_check_permission() {
448 let relationships = vec![
449 rel("document:readme", "reader", "user:alice"),
450 rel("document:readme", "writer", "user:bob"),
451 ];
452 let spicedb = match EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None) {
453 Ok(db) => db,
454 Err(e) => {
455 let msg = e.to_string();
456 if msg.contains("streaming proxy")
457 || msg.contains("bind")
458 || msg.contains("operation not permitted")
459 {
460 return;
461 }
462 panic!("EmbeddedSpiceDB::start_with_schema failed: {e}");
463 }
464 };
465
466 let response = spicedb
467 .permissions()
468 .check_permission(&check_req("document:readme", "read", "user:alice"))
469 .unwrap();
470 assert_eq!(
471 response.permissionship,
472 Permissionship::HasPermission as i32,
473 "alice should have read on document:readme"
474 );
475 }
476
477 #[test]
479 fn test_watch_streaming() {
480 let db = match EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None) {
481 Ok(d) => d,
482 Err(e) => {
483 let msg = e.to_string();
484 if msg.contains("streaming proxy")
485 || msg.contains("bind")
486 || msg.contains("operation not permitted")
487 {
488 return;
489 }
490 panic!("EmbeddedSpiceDB::start_with_schema failed: {e}");
491 }
492 };
493
494 let rt = tokio::runtime::Runtime::new().unwrap();
495 rt.block_on(async {
496 let channel = connect_streaming(db.streaming_address(), db.streaming_transport())
497 .await
498 .unwrap();
499 let mut watch_client = WatchServiceClient::new(channel);
500 let watch_req = WatchRequest {
502 optional_update_kinds: vec![
503 WatchKind::IncludeRelationshipUpdates.into(),
504 WatchKind::IncludeCheckpoints.into(),
505 ],
506 ..Default::default()
507 };
508 let mut stream =
509 match timeout(Duration::from_secs(10), watch_client.watch(watch_req)).await {
510 Ok(Ok(response)) => response.into_inner(),
511 Ok(Err(e)) => panic!("watch() failed: {e}"),
512 Err(_) => return,
513 };
514
515 let write_req = WriteRelationshipsRequest {
516 updates: vec![RelationshipUpdate {
517 operation: Operation::Touch as i32,
518 relationship: Some(rel("document:watched", "reader", "user:alice")),
519 }],
520 optional_preconditions: vec![],
521 optional_transaction_metadata: None,
522 };
523 db.permissions().write_relationships(&write_req).unwrap();
524
525 let mut received_update = false;
526 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
527 while tokio::time::Instant::now() < deadline {
528 match timeout(Duration::from_millis(200), stream.next()).await {
529 Ok(Some(Ok(resp))) => {
530 if !resp.updates.is_empty() {
531 received_update = true;
532 break;
533 }
534 }
535 Ok(Some(Err(e))) => panic!("watch stream error: {e}"),
536 Ok(None) => break,
537 Err(_) => {}
538 }
539 }
540 assert!(
541 received_update,
542 "expected at least one Watch response with updates within 3s"
543 );
544 });
545 }
546
547 #[test]
548 fn test_ffi_spicedb() {
549 let relationships = vec![
550 rel("document:readme", "reader", "user:alice"),
551 rel("document:readme", "writer", "user:bob"),
552 ];
553
554 let spicedb =
555 EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
556
557 assert_eq!(
558 spicedb
559 .permissions()
560 .check_permission(&check_req("document:readme", "read", "user:alice"))
561 .unwrap()
562 .permissionship,
563 Permissionship::HasPermission as i32,
564 );
565 assert_eq!(
566 spicedb
567 .permissions()
568 .check_permission(&check_req("document:readme", "write", "user:alice"))
569 .unwrap()
570 .permissionship,
571 Permissionship::NoPermission as i32,
572 );
573 assert_eq!(
574 spicedb
575 .permissions()
576 .check_permission(&check_req("document:readme", "read", "user:bob"))
577 .unwrap()
578 .permissionship,
579 Permissionship::HasPermission as i32,
580 );
581 assert_eq!(
582 spicedb
583 .permissions()
584 .check_permission(&check_req("document:readme", "write", "user:bob"))
585 .unwrap()
586 .permissionship,
587 Permissionship::HasPermission as i32,
588 );
589 assert_eq!(
590 spicedb
591 .permissions()
592 .check_permission(&check_req("document:readme", "read", "user:charlie"))
593 .unwrap()
594 .permissionship,
595 Permissionship::NoPermission as i32,
596 );
597 }
598
599 #[test]
600 fn test_add_relationship() {
601 let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
602
603 spicedb
604 .permissions()
605 .write_relationships(&WriteRelationshipsRequest {
606 updates: vec![RelationshipUpdate {
607 operation: Operation::Touch as i32,
608 relationship: Some(rel("document:test", "reader", "user:alice")),
609 }],
610 optional_preconditions: vec![],
611 optional_transaction_metadata: None,
612 })
613 .unwrap();
614
615 let r = spicedb
616 .permissions()
617 .check_permission(&check_req("document:test", "read", "user:alice"))
618 .unwrap();
619 assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
620 }
621
622 #[test]
623 fn test_parallel_instances() {
624 let spicedb1 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
625 let spicedb2 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
626
627 spicedb1
628 .permissions()
629 .write_relationships(&WriteRelationshipsRequest {
630 updates: vec![RelationshipUpdate {
631 operation: Operation::Touch as i32,
632 relationship: Some(rel("document:doc1", "reader", "user:alice")),
633 }],
634 optional_preconditions: vec![],
635 optional_transaction_metadata: None,
636 })
637 .unwrap();
638
639 let r1 = spicedb1
640 .permissions()
641 .check_permission(&check_req("document:doc1", "read", "user:alice"))
642 .unwrap();
643 assert_eq!(r1.permissionship, Permissionship::HasPermission as i32);
644
645 let r2 = spicedb2
646 .permissions()
647 .check_permission(&check_req("document:doc1", "read", "user:alice"))
648 .unwrap();
649 assert_eq!(r2.permissionship, Permissionship::NoPermission as i32);
650 }
651
652 #[tokio::test]
653 async fn test_read_relationships() {
654 let relationships = vec![
655 rel("document:doc1", "reader", "user:alice"),
656 rel("document:doc1", "reader", "user:bob"),
657 ];
658
659 let spicedb =
660 EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
661 let channel = connect_streaming(spicedb.streaming_address(), spicedb.streaming_transport())
662 .await
663 .unwrap();
664 let mut client =
665 spicedb_grpc_tonic::v1::permissions_service_client::PermissionsServiceClient::new(
666 channel,
667 );
668 let mut stream = client
669 .read_relationships(ReadRelationshipsRequest {
670 consistency: Some(fully_consistent()),
671 relationship_filter: Some(RelationshipFilter {
672 resource_type: "document".into(),
673 optional_resource_id: "doc1".into(),
674 optional_resource_id_prefix: String::new(),
675 optional_relation: String::new(),
676 optional_subject_filter: None,
677 }),
678 optional_limit: 0,
679 optional_cursor: None,
680 })
681 .await
682 .unwrap()
683 .into_inner();
684
685 let mut count = 0;
686 while let Some(Ok(_)) = stream.next().await {
687 count += 1;
688 }
689 assert_eq!(count, 2);
690 }
691
692 #[test]
694 #[ignore = "performance test - run manually with --ignored flag"]
695 fn perf_check_with_1000_relationships() {
696 const NUM_CHECKS: usize = 100;
697 use std::time::Instant;
698
699 let relationships: Vec<Relationship> = (0..1000)
701 .map(|i| {
702 rel(
703 &format!("document:doc{i}"),
704 "reader",
705 &format!("user:user{}", i % 100),
706 )
707 })
708 .collect();
709
710 println!("\n=== Performance: Check with 1000 relationships ===");
711
712 let start = Instant::now();
713 let spicedb =
714 EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
715 println!(
716 "Instance creation with 1000 relationships: {:?}",
717 start.elapsed()
718 );
719
720 for _ in 0..10 {
722 let _ = spicedb.permissions().check_permission(&check_req(
723 "document:doc0",
724 "read",
725 "user:user0",
726 ));
727 }
728
729 let start = Instant::now();
731 for i in 0..NUM_CHECKS {
732 let doc = format!("document:doc{}", i % 1000);
733 let user = format!("user:user{}", i % 100);
734 let _ = spicedb
735 .permissions()
736 .check_permission(&check_req(&doc, "read", &user))
737 .unwrap();
738 }
739 let elapsed = start.elapsed();
740
741 let num_checks_u32 = u32::try_from(NUM_CHECKS).unwrap();
742 println!("Total time for {NUM_CHECKS} checks: {elapsed:?}");
743 println!("Average per check: {:?}", elapsed / num_checks_u32);
744 println!(
745 "Checks per second: {:.0}",
746 f64::from(num_checks_u32) / elapsed.as_secs_f64()
747 );
748 }
749
750 #[test]
752 #[ignore = "performance test - run manually with --ignored flag"]
753 fn perf_add_individual_relationships() {
754 const NUM_ADDS: usize = 50;
755 use std::time::Instant;
756
757 let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
758
759 println!("\n=== Performance: Add individual relationships ===");
760
761 let start = Instant::now();
762 for i in 0..NUM_ADDS {
763 spicedb
764 .permissions()
765 .write_relationships(&WriteRelationshipsRequest {
766 updates: vec![RelationshipUpdate {
767 operation: Operation::Touch as i32,
768 relationship: Some(rel(
769 &format!("document:perf{i}"),
770 "reader",
771 "user:alice",
772 )),
773 }],
774 optional_preconditions: vec![],
775 optional_transaction_metadata: None,
776 })
777 .unwrap();
778 }
779 let elapsed = start.elapsed();
780
781 let num_adds_u32 = u32::try_from(NUM_ADDS).unwrap();
782 println!("Total time for {NUM_ADDS} individual adds: {elapsed:?}");
783 println!("Average per add: {:?}", elapsed / num_adds_u32);
784 println!(
785 "Adds per second: {:.0}",
786 f64::from(num_adds_u32) / elapsed.as_secs_f64()
787 );
788 }
789
790 #[test]
792 #[ignore = "performance test - run manually with --ignored flag"]
793 fn perf_bulk_write_relationships() {
794 use std::time::Instant;
795
796 let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
797
798 println!("\n=== Performance: Bulk write relationships ===");
799
800 for batch_size in [5_i32, 10, 20, 50] {
802 let batch_size_u32 = u32::try_from(batch_size).unwrap();
803 let relationships: Vec<Relationship> = (0..batch_size)
804 .map(|i| {
805 rel(
806 &format!("document:bulk{batch_size}_{i}"),
807 "reader",
808 "user:alice",
809 )
810 })
811 .collect();
812
813 let start = Instant::now();
814 spicedb
815 .permissions()
816 .write_relationships(&WriteRelationshipsRequest {
817 updates: relationships
818 .iter()
819 .map(|r| RelationshipUpdate {
820 operation: Operation::Touch as i32,
821 relationship: Some(r.clone()),
822 })
823 .collect(),
824 optional_preconditions: vec![],
825 optional_transaction_metadata: None,
826 })
827 .unwrap();
828 let elapsed = start.elapsed();
829
830 println!(
831 "Batch of {} relationships: {:?} ({:?} per relationship)",
832 batch_size,
833 elapsed,
834 elapsed / batch_size_u32
835 );
836 }
837
838 println!("\n--- Comparison: 10 individual vs 10 bulk ---");
840
841 let spicedb2 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
842
843 let start = Instant::now();
844 for i in 0..10 {
845 spicedb2
846 .permissions()
847 .write_relationships(&WriteRelationshipsRequest {
848 updates: vec![RelationshipUpdate {
849 operation: Operation::Touch as i32,
850 relationship: Some(rel(
851 &format!("document:cmp_ind{i}"),
852 "reader",
853 "user:bob",
854 )),
855 }],
856 optional_preconditions: vec![],
857 optional_transaction_metadata: None,
858 })
859 .unwrap();
860 }
861 let individual_time = start.elapsed();
862 println!("10 individual adds: {individual_time:?}");
863
864 let relationships: Vec<Relationship> = (0..10)
865 .map(|i| rel(&format!("document:cmp_bulk{i}"), "reader", "user:bob"))
866 .collect();
867 let start = Instant::now();
868 spicedb2
869 .permissions()
870 .write_relationships(&WriteRelationshipsRequest {
871 updates: relationships
872 .iter()
873 .map(|r| RelationshipUpdate {
874 operation: Operation::Touch as i32,
875 relationship: Some(r.clone()),
876 })
877 .collect(),
878 optional_preconditions: vec![],
879 optional_transaction_metadata: None,
880 })
881 .unwrap();
882 let bulk_time = start.elapsed();
883 println!("10 bulk add: {bulk_time:?}");
884 println!(
885 "Speedup: {:.1}x",
886 individual_time.as_secs_f64() / bulk_time.as_secs_f64()
887 );
888 }
889
890 #[test]
892 #[ignore = "performance test - run manually with --ignored flag"]
893 fn perf_embedded_50k_relationships() {
894 const TOTAL_RELS: usize = 50_000;
895 const BATCH_SIZE: usize = 1000;
896 const NUM_CHECKS: usize = 500;
897 use std::time::Instant;
898
899 println!("\n=== Embedded SpiceDB: 50,000 relationships ===");
900
901 println!("Creating instance...");
903 let start = Instant::now();
904 let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
905 println!("Instance creation time: {:?}", start.elapsed());
906
907 println!("Adding {TOTAL_RELS} relationships in batches of {BATCH_SIZE}...");
908 let start = Instant::now();
909 for batch_num in 0..(TOTAL_RELS / BATCH_SIZE) {
910 let batch_start = batch_num * BATCH_SIZE;
911 let relationships: Vec<Relationship> = (batch_start..batch_start + BATCH_SIZE)
912 .map(|i| {
913 rel(
914 &format!("document:doc{i}"),
915 "reader",
916 &format!("user:user{}", i % 1000),
917 )
918 })
919 .collect();
920 spicedb
921 .permissions()
922 .write_relationships(&WriteRelationshipsRequest {
923 updates: relationships
924 .iter()
925 .map(|r| RelationshipUpdate {
926 operation: Operation::Touch as i32,
927 relationship: Some(r.clone()),
928 })
929 .collect(),
930 optional_preconditions: vec![],
931 optional_transaction_metadata: None,
932 })
933 .unwrap();
934 }
935 println!(
936 "Total time to add {} relationships: {:?}",
937 TOTAL_RELS,
938 start.elapsed()
939 );
940
941 for _ in 0..20 {
943 let _ = spicedb.permissions().check_permission(&check_req(
944 "document:doc0",
945 "read",
946 "user:user0",
947 ));
948 }
949
950 let num_checks_u32 = u32::try_from(NUM_CHECKS).unwrap();
952 let start = Instant::now();
953 for i in 0..NUM_CHECKS {
954 let doc = format!("document:doc{}", i % TOTAL_RELS);
955 let user = format!("user:user{}", i % 1000);
956 let _ = spicedb
957 .permissions()
958 .check_permission(&check_req(&doc, "read", &user))
959 .unwrap();
960 }
961 let elapsed = start.elapsed();
962
963 println!("Total time for {NUM_CHECKS} checks: {elapsed:?}");
964 println!("Average per check: {:?}", elapsed / num_checks_u32);
965 println!(
966 "Checks per second: {:.0}",
967 f64::from(num_checks_u32) / elapsed.as_secs_f64()
968 );
969
970 let start = Instant::now();
972 for i in 0..NUM_CHECKS {
973 let doc = format!("document:doc{}", i % TOTAL_RELS);
974 let _ = spicedb
976 .permissions()
977 .check_permission(&check_req(&doc, "read", "user:nonexistent"))
978 .unwrap();
979 }
980 let elapsed = start.elapsed();
981 println!("\nNegative checks (user not found):");
982 println!("Average per check: {:?}", elapsed / num_checks_u32);
983 }
984
985 #[cfg(all(not(target_os = "windows"), target_arch = "x86_64"))]
991 mod datastore_shared {
992 const LINUX_AMD64: &str = "linux/amd64";
994 use std::process::Command;
995
996 use testcontainers_modules::{
997 cockroach_db, mysql, postgres,
998 testcontainers::{
999 GenericImage, ImageExt,
1000 core::{IntoContainerPort, WaitFor},
1001 runners::AsyncRunner,
1002 },
1003 };
1004
1005 use super::*;
1006 use crate::StartOptions;
1007
1008 fn run_migrate(engine: &str, uri: &str, extra_args: &[(&str, &str)]) -> Result<(), String> {
1011 let mut cmd = Command::new("spicedb");
1012 cmd.args([
1013 "datastore",
1014 "migrate",
1015 "head",
1016 "--datastore-engine",
1017 engine,
1018 "--datastore-conn-uri",
1019 uri,
1020 ]);
1021 for (k, v) in extra_args {
1022 cmd.arg(format!("--{k}={v}"));
1023 }
1024 let output = cmd
1025 .output()
1026 .map_err(|e| format!("spicedb migrate failed (is spicedb in PATH?): {e}"))?;
1027 if output.status.success() {
1028 Ok(())
1029 } else {
1030 let stderr = String::from_utf8_lossy(&output.stderr);
1031 Err(format!("spicedb migrate failed: {stderr}"))
1032 }
1033 }
1034
1035 fn run_shared_datastore_test(datastore: &str, datastore_uri: &str) {
1037 run_migrate(datastore, datastore_uri, &[]).expect("migration must succeed");
1038
1039 let opts = StartOptions {
1040 datastore: Some(datastore.into()),
1041 datastore_uri: Some(datastore_uri.into()),
1042 ..Default::default()
1043 };
1044
1045 let schema = TEST_SCHEMA;
1046 let db1 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1047 let db2 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1048
1049 db1.permissions()
1051 .write_relationships(&WriteRelationshipsRequest {
1052 updates: vec![RelationshipUpdate {
1053 operation: Operation::Touch as i32,
1054 relationship: Some(rel("document:shared", "reader", "user:alice")),
1055 }],
1056 optional_preconditions: vec![],
1057 optional_transaction_metadata: None,
1058 })
1059 .unwrap();
1060
1061 let r = db2
1063 .permissions()
1064 .check_permission(&check_req("document:shared", "read", "user:alice"))
1065 .unwrap();
1066 assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
1067 }
1068
1069 #[tokio::test]
1070 async fn datastore_shared_postgres() {
1071 let container = postgres::Postgres::default()
1073 .with_tag("17")
1074 .with_platform(LINUX_AMD64)
1075 .start()
1076 .await
1077 .unwrap();
1078 let host = container.get_host().await.unwrap();
1079 let port = container.get_host_port_ipv4(5432).await.unwrap();
1080 let uri = format!("postgres://postgres:postgres@{host}:{port}/postgres");
1081 run_shared_datastore_test("postgres", &uri);
1082 }
1083
1084 #[tokio::test]
1085 async fn datastore_shared_cockroachdb() {
1086 let container = cockroach_db::CockroachDb::default()
1087 .with_platform(LINUX_AMD64)
1088 .start()
1089 .await
1090 .unwrap();
1091 let host = container.get_host().await.unwrap();
1092 let port = container.get_host_port_ipv4(26257).await.unwrap();
1093 let uri = format!("postgres://root@{host}:{port}/defaultdb?sslmode=disable");
1094 run_shared_datastore_test("cockroachdb", &uri);
1095 }
1096
1097 #[tokio::test]
1098 async fn datastore_shared_mysql() {
1099 let container = mysql::Mysql::default()
1100 .with_platform(LINUX_AMD64)
1101 .start()
1102 .await
1103 .unwrap();
1104 let host = container.get_host().await.unwrap();
1105 let port = container.get_host_port_ipv4(3306).await.unwrap();
1106 let uri = format!("root@tcp({host}:{port})/test?parseTime=true");
1108 run_shared_datastore_test("mysql", &uri);
1109 }
1110
1111 #[tokio::test]
1112 async fn datastore_shared_spanner() {
1113 let container = GenericImage::new("roryq/spanner-emulator", "latest")
1116 .with_exposed_port(9010u16.tcp())
1117 .with_wait_for(WaitFor::seconds(5))
1118 .with_platform(LINUX_AMD64)
1119 .with_env_var("SPANNER_PROJECT_ID", "test-project")
1120 .with_env_var("SPANNER_INSTANCE_ID", "test-instance")
1121 .with_env_var("SPANNER_DATABASE_ID", "test-db")
1122 .start()
1123 .await
1124 .unwrap();
1125 let host = container.get_host().await.unwrap();
1126 let port = container.get_host_port_ipv4(9010u16.tcp()).await.unwrap();
1127 let emulator_host = format!("{host}:{port}");
1128 let uri = "projects/test-project/instances/test-instance/databases/test-db".to_string();
1129
1130 run_migrate(
1131 "spanner",
1132 &uri,
1133 &[("datastore-spanner-emulator-host", &emulator_host)],
1134 )
1135 .expect("migration must succeed");
1136
1137 let opts = StartOptions {
1138 datastore: Some("spanner".into()),
1139 datastore_uri: Some(uri),
1140 spanner_emulator_host: Some(emulator_host),
1141 ..Default::default()
1142 };
1143
1144 let schema = TEST_SCHEMA;
1145 let db1 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1146 let db2 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1147
1148 db1.permissions()
1149 .write_relationships(&WriteRelationshipsRequest {
1150 updates: vec![RelationshipUpdate {
1151 operation: Operation::Touch as i32,
1152 relationship: Some(rel("document:shared", "reader", "user:alice")),
1153 }],
1154 optional_preconditions: vec![],
1155 optional_transaction_metadata: None,
1156 })
1157 .unwrap();
1158
1159 let r = db2
1160 .permissions()
1161 .check_permission(&check_req("document:shared", "read", "user:alice"))
1162 .unwrap();
1163 assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
1164 }
1165 }
1166}