diff --git a/Cargo.lock b/Cargo.lock index 6941a5a57..89a21670a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1912,6 +1912,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spider-client" +version = "0.1.0" +dependencies = [ + "spider-core", + "spider-proto-rust", + "spider-utils", + "thiserror", + "tokio", + "tonic", +] + [[package]] name = "spider-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a91cc9f27..c624e3e7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "components/spider-client", "components/spider-core", "components/spider-derive", "components/spider-execution-manager", @@ -20,6 +21,7 @@ members = [ "tests/huntsman/test-utils", ] default-members = [ + "components/spider-client", "components/spider-core", "components/spider-derive", "components/spider-execution-manager", diff --git a/components/spider-client/Cargo.toml b/components/spider-client/Cargo.toml new file mode 100644 index 000000000..4f7fc796d --- /dev/null +++ b/components/spider-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "spider-client" +version = "0.1.0" +edition = "2024" + +[lib] +name = "spider_client" +path = "src/lib.rs" + +[dependencies] +spider-core = { path = "../spider-core" } +spider-proto-rust = { path = "../spider-proto-rust" } +spider-utils = { path = "../spider-utils" } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["macros"] } +tonic = "0.14.6" diff --git a/components/spider-client/src/client.rs b/components/spider-client/src/client.rs new file mode 100644 index 000000000..42affea0e --- /dev/null +++ b/components/spider-client/src/client.rs @@ -0,0 +1,227 @@ +//! [`SpiderClient`] — the top-level handle holding the gRPC connection pools. + +use std::num::NonZeroUsize; + +use spider_core::{ + job::JobState, + task::TaskGraph, + types::{ + id::{JobId, ResourceGroupId}, + io::{TaskInput, TaskOutput}, + }, +}; +use tonic::transport::Endpoint; + +use crate::{ + error::ClientError, + grpc::{job::JobOrchestrationClient, resource_group::ResourceGroupManagementClient}, +}; + +/// User-facing client for the Spider storage gRPC services. +#[derive(Debug, Clone)] +pub struct SpiderClient { + job_orchestration: JobOrchestrationClient, + resource_group: ResourceGroupManagementClient, +} + +impl SpiderClient { + /// Connects pools of `pool_size` connections to the storage gRPC endpoint. + /// + /// # Returns + /// + /// A new [`SpiderClient`] connected to `endpoint` on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::Transport`] if tonic cannot create or connect to the endpoint. + pub async fn connect(endpoint: Endpoint, pool_size: NonZeroUsize) -> Result { + let (job_orchestration, resource_group) = tokio::try_join!( + JobOrchestrationClient::connect(endpoint.clone(), pool_size), + ResourceGroupManagementClient::connect(endpoint, pool_size), + )?; + + Ok(Self { + job_orchestration, + resource_group, + }) + } + + /// Serializes and zstd-compresses the task graph and inputs, registers the job, and returns its + /// assigned id. + /// + /// # Returns + /// + /// The [`JobId`] the storage server assigned to the registered job on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::Serialization`] if the task graph or inputs cannot be serialized or + /// compressed. + /// * [`ClientError::InvalidArgument`] if the storage server rejects the task graph or inputs. + /// * [`ClientError::Unauthenticated`] if the resource group is unknown or unauthorized. + /// * [`ClientError::Transport`] if the gRPC transport fails or the connection is lost. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn submit_job( + &self, + resource_group_id: ResourceGroupId, + task_graph: &TaskGraph, + inputs: Vec, + ) -> Result { + self.job_orchestration + .submit_job(resource_group_id, task_graph, inputs) + .await + } + + /// Starts a registered job. + /// + /// # Returns + /// + /// The job's [`JobState`] after the start request is accepted on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::JobNotFound`] if no job with `job_id` exists. + /// * [`ClientError::InvalidJobState`] if the job is not in a state that allows starting. + /// * [`ClientError::UnspecifiedJobState`] if the server reports an unspecified job state. + /// * [`ClientError::Transport`] if the gRPC transport fails, the connection is lost, or the + /// server reports an unrecognized job state. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn start_job(&self, job_id: JobId) -> Result { + self.job_orchestration.start_job(job_id).await + } + + /// Cancels a job. + /// + /// # Returns + /// + /// The job's [`JobState`] after the cancellation request is accepted on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::JobNotFound`] if no job with `job_id` exists. + /// * [`ClientError::InvalidJobState`] if the job is not in a state that allows cancellation. + /// * [`ClientError::UnspecifiedJobState`] if the server reports an unspecified job state. + /// * [`ClientError::Transport`] if the gRPC transport fails, the connection is lost, or the + /// server reports an unrecognized job state. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn cancel_job(&self, job_id: JobId) -> Result { + self.job_orchestration.cancel_job(job_id).await + } + + /// Gets the current state of a job. + /// + /// # Returns + /// + /// The job's current [`JobState`] on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::JobNotFound`] if no job with `job_id` exists. + /// * [`ClientError::UnspecifiedJobState`] if the server reports an unspecified job state. + /// * [`ClientError::Transport`] if the gRPC transport fails, the connection is lost, or the + /// server reports an unrecognized job state. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn get_job_state(&self, job_id: JobId) -> Result { + self.job_orchestration.get_job_state(job_id).await + } + + /// Gets a job's task outputs. + /// + /// # Returns + /// + /// The job's outputs, deserialized from the storage wire format into opaque msgpack payloads, + /// on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::JobNotFound`] if no job with `job_id` exists. + /// * [`ClientError::InvalidJobState`] if the job has not yet succeeded. + /// * [`ClientError::Deserialization`] if the returned outputs cannot be decompressed or + /// unframed. + /// * [`ClientError::Transport`] if the gRPC transport fails or the connection is lost. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn get_job_outputs(&self, job_id: JobId) -> Result, ClientError> { + self.job_orchestration.get_job_outputs(job_id).await + } + + /// Gets a job's error message. + /// + /// # Returns + /// + /// The job's error message on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::JobNotFound`] if no job with `job_id` exists. + /// * [`ClientError::InvalidJobState`] if the job has not yet failed. + /// * [`ClientError::Transport`] if the gRPC transport fails or the connection is lost. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn get_job_error(&self, job_id: JobId) -> Result { + self.job_orchestration.get_job_error(job_id).await + } + + /// Registers an external resource group and returns its server-assigned id. + /// + /// # Returns + /// + /// The [`ResourceGroupId`] the storage server assigned to the registered resource group on + /// success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::InvalidArgument`] if the storage server rejects the request as invalid. + /// * [`ClientError::Unauthenticated`] if the resource group is unknown or the password is + /// invalid. + /// * [`ClientError::Transport`] if the gRPC transport fails or the connection is lost. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn add_resource_group( + &self, + external_resource_group_id: String, + password: Vec, + ) -> Result { + self.resource_group + .add_resource_group(external_resource_group_id, password) + .await + } + + /// Verifies a resource group's password. + /// + /// # Returns + /// + /// `Ok(())` on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::InvalidArgument`] if the storage server rejects the request as invalid. + /// * [`ClientError::Unauthenticated`] if the resource group is unknown or the password is + /// invalid. + /// * [`ClientError::Transport`] if the gRPC transport fails or the connection is lost. + /// * [`ClientError::Server`] for any other server-reported error. + pub async fn verify_resource_group( + &self, + resource_group_id: ResourceGroupId, + password: Vec, + ) -> Result<(), ClientError> { + self.resource_group + .verify_resource_group(resource_group_id, password) + .await + } +} diff --git a/components/spider-client/src/error.rs b/components/spider-client/src/error.rs new file mode 100644 index 000000000..291de8790 --- /dev/null +++ b/components/spider-client/src/error.rs @@ -0,0 +1,50 @@ +//! Client error type for the Spider storage gRPC services. + +/// Errors returned by [`crate::client::SpiderClient`] operations. +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + /// The gRPC transport failed or the connection was lost or unestablished. + #[error("transport error: {0}")] + Transport(String), + + /// The storage server returned an otherwise-uncategorized error. + #[error("storage server error: {0}")] + Server(String), + + /// No job with the requested identifier exists. + #[error("job not found")] + JobNotFound, + + /// The job is not in a state that allows the requested operation. + #[error("invalid job state: {0}")] + InvalidJobState(String), + + /// The storage server rejected the request as invalid. + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// The resource group or password was rejected. + #[error("unauthenticated: {0}")] + Unauthenticated(String), + + /// A failure to serialize, compress, or wire-frame a request payload. + #[error("serialization error: {0}")] + Serialization(String), + + /// A failure to deserialize, decompress, or wire-frame a response payload. + #[error("deserialization error: {0}")] + Deserialization(String), + + /// The server returned an unspecified job state that has no core representation. + #[error("job state unspecified")] + UnspecifiedJobState, +} + +/// Converts a displayable transport-layer error into [`ClientError::Transport`]. +/// +/// # Returns +/// +/// A [`ClientError::Transport`] containing `error`'s display string. +pub(crate) fn to_transport_error(error: impl std::fmt::Display) -> ClientError { + ClientError::Transport(error.to_string()) +} diff --git a/components/spider-client/src/grpc/job.rs b/components/spider-client/src/grpc/job.rs new file mode 100644 index 000000000..a430ced28 --- /dev/null +++ b/components/spider-client/src/grpc/job.rs @@ -0,0 +1,301 @@ +//! gRPC client implementation wrapping [`JobOrchestrationServiceClient`]. + +use std::num::NonZeroUsize; + +use spider_core::{ + compression::encode_zstd_bytes, + job::JobState, + task::TaskGraph, + types::{ + id::{JobId, ResourceGroupId}, + io::{SerializedTaskOutputs, TaskInput, TaskInputsSerializer, TaskOutput}, + }, +}; +use spider_proto_rust::{ + error::Error as ProtoError, + storage::{self, job_orchestration_service_client::JobOrchestrationServiceClient}, +}; +use spider_utils::grpc::client::ConnectionPool; +use tonic::{ + Code, + Status, + transport::{Channel, Endpoint}, +}; + +use crate::error::{ClientError, to_transport_error}; + +/// gRPC client for the storage server's job-orchestration service. +#[derive(Debug, Clone)] +pub struct JobOrchestrationClient { + connection_pool: ConnectionPool>, +} + +impl JobOrchestrationClient { + /// Connects a pool of `pool_size` connections to the job-orchestration gRPC endpoint. + /// + /// # Returns + /// + /// A new [`JobOrchestrationClient`] connected to `endpoint` on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::Transport`] if tonic cannot create or connect to the endpoint. + pub async fn connect(endpoint: Endpoint, pool_size: NonZeroUsize) -> Result { + let connection_pool = ConnectionPool::connect(endpoint, pool_size, |channel| { + JobOrchestrationServiceClient::new(channel) + }) + .await + .map_err(to_transport_error)?; + + Ok(Self { connection_pool }) + } + + /// Serializes and zstd-compresses the task graph and inputs, registers the job, and returns + /// its assigned id. + /// + /// # Returns + /// + /// The [`JobId`] the storage server assigned to the registered job on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`TaskGraph::to_zstd_compressed_json`]'s return values on failure as + /// [`ClientError::Serialization`]. + /// * Forwards [`JobOrchestrationServiceClient::register_job`]'s status on failure. + pub async fn submit_job( + &self, + resource_group_id: ResourceGroupId, + task_graph: &TaskGraph, + inputs: Vec, + ) -> Result { + let compressed_serialized_task_graph = task_graph + .to_zstd_compressed_json() + .map_err(|error| ClientError::Serialization(error.to_string()))?; + let compressed_serialized_inputs = serialize_inputs(inputs)?; + let request = storage::RegisterJobRequest { + resource_group_id: resource_group_id.get(), + compressed_serialized_task_graph, + compressed_serialized_inputs, + }; + let response = self + .connection_pool + .get_client() + .register_job(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + Ok(JobId::from(response.job_id)) + } + + /// Starts a registered job. + /// + /// # Returns + /// + /// The job's [`JobState`] after the start request is accepted on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`JobOrchestrationServiceClient::start_job`]'s status on failure. + /// * Forwards [`job_state_response_to_result`]'s return values on failure. + pub async fn start_job(&self, job_id: JobId) -> Result { + let request = storage::JobIdRequest { + job_id: job_id.get(), + }; + let response = self + .connection_pool + .get_client() + .start_job(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + job_state_response_to_result(response) + } + + /// Cancels a job. + /// + /// # Returns + /// + /// The job's [`JobState`] after the cancellation request is accepted on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`JobOrchestrationServiceClient::cancel_job`]'s status on failure. + /// * Forwards [`job_state_response_to_result`]'s return values on failure. + pub async fn cancel_job(&self, job_id: JobId) -> Result { + let request = storage::JobIdRequest { + job_id: job_id.get(), + }; + let response = self + .connection_pool + .get_client() + .cancel_job(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + job_state_response_to_result(response) + } + + /// Gets the current state of a job. + /// + /// # Returns + /// + /// The job's current [`JobState`] on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`JobOrchestrationServiceClient::get_job_state`]'s status on failure. + /// * Forwards [`job_state_response_to_result`]'s return values on failure. + pub async fn get_job_state(&self, job_id: JobId) -> Result { + let request = storage::JobIdRequest { + job_id: job_id.get(), + }; + let response = self + .connection_pool + .get_client() + .get_job_state(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + job_state_response_to_result(response) + } + + /// Gets a job's task outputs. + /// + /// # Returns + /// + /// The job's outputs, deserialized from the storage wire format into opaque msgpack payloads, + /// on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`SerializedTaskOutputs::deserialize_from_raw`]'s return values on failure as + /// [`ClientError::Deserialization`]. + /// * Forwards [`JobOrchestrationServiceClient::get_job_outputs`]'s status on failure. + pub async fn get_job_outputs(&self, job_id: JobId) -> Result, ClientError> { + let request = storage::JobIdRequest { + job_id: job_id.get(), + }; + let response = self + .connection_pool + .get_client() + .get_job_outputs(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + SerializedTaskOutputs::deserialize_from_raw(&response.serialized_outputs) + .map_err(|error| ClientError::Deserialization(error.to_string())) + } + + /// Gets a job's error message. + /// + /// # Returns + /// + /// The job's error message on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`JobOrchestrationServiceClient::get_job_error`]'s status on failure. + pub async fn get_job_error(&self, job_id: JobId) -> Result { + let request = storage::JobIdRequest { + job_id: job_id.get(), + }; + let response = self + .connection_pool + .get_client() + .get_job_error(request) + .await + .map_err(|status| job_status_to_error(&status))? + .into_inner(); + + Ok(response.error_message) + } +} + +/// Serializes and zstd-compresses a job's task inputs for the +/// [`JobOrchestrationServiceClient::register_job`] request. +/// +/// # Returns +/// +/// The zstd-compressed wire-format input bytes on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * [`ClientError::Serialization`] if an input cannot be framed or the wire buffer cannot be +/// compressed. +fn serialize_inputs(inputs: Vec) -> Result, ClientError> { + let mut serializer = TaskInputsSerializer::new(); + for input in inputs { + serializer + .append(input) + .map_err(|error| ClientError::Serialization(error.to_string()))?; + } + encode_zstd_bytes(&serializer.release()) + .map_err(|error| ClientError::Serialization(error.to_string())) +} + +/// Converts a [`storage::JobStateResponse`] into a [`JobState`]. +/// +/// # Returns +/// +/// The [`JobState`] carried by `response` on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * [`ClientError::UnspecifiedJobState`] if the server reports an unspecified job state. +/// * [`ClientError::Transport`] if `response` carries an unrecognized job state. +fn job_state_response_to_result( + response: storage::JobStateResponse, +) -> Result { + let proto_state = storage::JobState::try_from(response.state) + .map_err(|error| ClientError::Transport(error.to_string()))?; + JobState::try_from(proto_state).map_err(|error| match error { + ProtoError::JobStateUnspecified => ClientError::UnspecifiedJobState, + other => ClientError::Transport(other.to_string()), + }) +} + +/// Maps a job-orchestration gRPC [`Status`] to a [`ClientError`]. +/// +/// # Returns +/// +/// The [`ClientError`] for `status`'s code: +/// +/// * [`ClientError::JobNotFound`] for `NOT_FOUND`. +/// * [`ClientError::InvalidJobState`] for `FAILED_PRECONDITION`. +/// * [`ClientError::InvalidArgument`] for `INVALID_ARGUMENT`. +/// * [`ClientError::Unauthenticated`] for `UNAUTHENTICATED`. +/// * [`ClientError::Transport`] for `UNAVAILABLE` (a lost or unestablished connection). +/// * [`ClientError::Server`] for any other code. +fn job_status_to_error(status: &Status) -> ClientError { + match status.code() { + Code::NotFound => ClientError::JobNotFound, + Code::FailedPrecondition => ClientError::InvalidJobState(status.message().to_owned()), + Code::InvalidArgument => ClientError::InvalidArgument(status.message().to_owned()), + Code::Unauthenticated => ClientError::Unauthenticated(status.message().to_owned()), + Code::Unavailable => ClientError::Transport(status.message().to_owned()), + _ => ClientError::Server(status.message().to_owned()), + } +} diff --git a/components/spider-client/src/grpc/mod.rs b/components/spider-client/src/grpc/mod.rs new file mode 100644 index 000000000..b0091f653 --- /dev/null +++ b/components/spider-client/src/grpc/mod.rs @@ -0,0 +1,4 @@ +//! gRPC clients implementation for Spider services. + +pub mod job; +pub mod resource_group; diff --git a/components/spider-client/src/grpc/resource_group.rs b/components/spider-client/src/grpc/resource_group.rs new file mode 100644 index 000000000..671f2b8a9 --- /dev/null +++ b/components/spider-client/src/grpc/resource_group.rs @@ -0,0 +1,127 @@ +//! gRPC client implementation wrapping [`ResourceGroupManagementServiceClient`]. + +use std::num::NonZeroUsize; + +use spider_core::types::id::ResourceGroupId; +use spider_proto_rust::storage::{ + self, + resource_group_management_service_client::ResourceGroupManagementServiceClient, +}; +use spider_utils::grpc::client::ConnectionPool; +use tonic::{ + Code, + Status, + transport::{Channel, Endpoint}, +}; + +use crate::error::{ClientError, to_transport_error}; + +/// gRPC client for the storage server's resource-group-management service. +#[derive(Debug, Clone)] +pub struct ResourceGroupManagementClient { + connection_pool: ConnectionPool>, +} + +impl ResourceGroupManagementClient { + /// Connects a pool of `pool_size` connections to the resource-group-management gRPC endpoint. + /// + /// # Returns + /// + /// A new [`ResourceGroupManagementClient`] connected to `endpoint` on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ClientError::Transport`] if tonic cannot create or connect to the endpoint. + pub async fn connect(endpoint: Endpoint, pool_size: NonZeroUsize) -> Result { + let connection_pool = ConnectionPool::connect(endpoint, pool_size, |channel| { + ResourceGroupManagementServiceClient::new(channel) + }) + .await + .map_err(to_transport_error)?; + + Ok(Self { connection_pool }) + } + + /// Registers an external resource group and returns its server-assigned id. + /// + /// # Returns + /// + /// The [`ResourceGroupId`] the storage server assigned to the registered resource group on + /// success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`ResourceGroupManagementServiceClient::add_resource_group`]'s status on failure. + pub async fn add_resource_group( + &self, + external_resource_group_id: String, + password: Vec, + ) -> Result { + let request = storage::AddResourceGroupRequest { + external_resource_group_id, + password, + }; + let response = self + .connection_pool + .get_client() + .add_resource_group(request) + .await + .map_err(|status| resource_group_status_to_error(&status))? + .into_inner(); + + Ok(ResourceGroupId::from(response.resource_group_id)) + } + + /// Verifies a resource group's password. + /// + /// # Returns + /// + /// `Ok(())` on success — the storage server's response is empty, so success is implicit. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`ResourceGroupManagementServiceClient::verify_resource_group`]'s status on + /// failure. + pub async fn verify_resource_group( + &self, + resource_group_id: ResourceGroupId, + password: Vec, + ) -> Result<(), ClientError> { + let request = storage::VerifyResourceGroupRequest { + resource_group_id: resource_group_id.get(), + password, + }; + self.connection_pool + .get_client() + .verify_resource_group(request) + .await + .map_err(|status| resource_group_status_to_error(&status))?; + Ok(()) + } +} + +/// Maps a resource-group-management gRPC [`Status`] to a [`ClientError`]. +/// +/// # Returns +/// +/// The [`ClientError`] for `status`'s code: +/// +/// * [`ClientError::InvalidArgument`] for `INVALID_ARGUMENT`. +/// * [`ClientError::Unauthenticated`] for `UNAUTHENTICATED` (an unknown or unauthorized resource +/// group, or an invalid password). +/// * [`ClientError::Transport`] for `UNAVAILABLE` (a lost or unestablished connection). +/// * [`ClientError::Server`] for any other code. +fn resource_group_status_to_error(status: &Status) -> ClientError { + match status.code() { + Code::InvalidArgument => ClientError::InvalidArgument(status.message().to_owned()), + Code::Unauthenticated => ClientError::Unauthenticated(status.message().to_owned()), + Code::Unavailable => ClientError::Transport(status.message().to_owned()), + _ => ClientError::Server(status.message().to_owned()), + } +} diff --git a/components/spider-client/src/lib.rs b/components/spider-client/src/lib.rs new file mode 100644 index 000000000..61e3d2f93 --- /dev/null +++ b/components/spider-client/src/lib.rs @@ -0,0 +1,14 @@ +//! User-facing client library for the Spider services. +//! +//! This library provides the Rust API for interacting with the Spider services to: +//! +//! * Managing Spider resource groups. +//! * Orchestrating Spider jobs. +//! +//! See [`SpiderClient`] for the main client API. + +pub mod client; +pub mod error; +pub(crate) mod grpc; + +pub use client::SpiderClient;