Skip to main content

dex_sdk/
worker.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Super Durable Source License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
9use std::collections::HashMap;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU8, Ordering};
13
14use dex_protocol::dex::WorkerErrorResponse;
15use dex_protocol::dex::flow_service_client::FlowServiceClient;
16use dex_protocol::dex::worker_service_server::{WorkerService, WorkerServiceServer};
17use dex_protocol::dex::{
18    InvokeExecuteMethodRequest, InvokeExecuteMethodResponse, InvokeWaitForMethodRequest,
19    InvokeWaitForMethodResponse, InvokeWorkerRpcRequest, InvokeWorkerRpcResponse,
20    SyncAttributeIndexRequest,
21};
22use prost::Message;
23use prost_types::Any;
24use tokio::runtime::Runtime;
25use tokio::sync::watch;
26use tonic::transport::{Channel, Endpoint, Server};
27use tonic::{Code, Request, Response, Status};
28
29use crate::value_hydrator::ValueHydrator;
30use crate::worker_dispatcher::WorkerDispatcher;
31use crate::{BlobCache, HandlerError, Registry, SdkError, SdkResult, WorkerOptions, WorkerTarget};
32
33const CREATED: u8 = 0;
34const RUNNING: u8 = 1;
35const STOPPED: u8 = 2;
36
37/// Hosts registered Flow Step and RPC handlers over application WorkerService.
38///
39/// A Worker owns an internal Tokio runtime and is one-shot. [`Self::start`] first synchronizes
40/// Attribute indexes with Dex, then blocks the calling thread while serving gRPC. Call [`Self::stop`]
41/// from another thread to request graceful server shutdown.
42///
43/// # Examples
44///
45/// ```no_run
46/// use dex_sdk::{BlobCache, BlobCacheConfig, Registry, Worker, WorkerOptions};
47/// use std::sync::Arc;
48///
49/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
50///     "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
51/// )?)?);
52/// let worker = Worker::try_new(Registry::new(), cache, WorkerOptions::new())?;
53/// println!("advertise {}", worker.worker_target().address());
54/// worker.start()?;
55/// # Ok::<(), Box<dyn std::error::Error>>(())
56/// ```
57pub struct Worker {
58    runtime: Runtime,
59    service: RustWorkerService,
60    flow_service: FlowServiceClient<Channel>,
61    attribute_indexes: HashMap<String, i32>,
62    attribute_index_sync_timeout: std::time::Duration,
63    bind_address: SocketAddr,
64    worker_target: WorkerTarget,
65    shutdown: watch::Sender<bool>,
66    state: AtomicU8,
67}
68
69impl Worker {
70    /// Creates a Worker or panics when options or runtime initialization are invalid.
71    ///
72    /// # Panics
73    ///
74    /// Panics for an invalid bind address, zero index-sync timeout, invalid Dex endpoint, or Tokio
75    /// runtime creation failure. Prefer [`Self::try_new`] for recoverable startup.
76    pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: WorkerOptions) -> Self {
77        Self::try_new(registry, blob_cache, options)
78            .unwrap_or_else(|error| panic!("cannot create Rust Worker: {error}"))
79    }
80
81    /// Creates a one-shot Worker without binding its listener.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`SdkError::FlowDefinition`] for invalid Worker options or Service for runtime and
86    /// endpoint initialization failures. The Dex connection is lazy.
87    pub fn try_new(
88        registry: Registry,
89        blob_cache: Arc<BlobCache>,
90        options: WorkerOptions,
91    ) -> SdkResult<Self> {
92        let runtime = Runtime::new().map_err(service_error)?;
93        if options.attribute_index_sync_timeout_value().is_zero() {
94            return Err(SdkError::FlowDefinition {
95                message: "attribute index sync timeout must be positive".to_string(),
96            });
97        }
98        let bind_address = options
99            .bind_address_value()
100            .parse::<SocketAddr>()
101            .map_err(|error| SdkError::FlowDefinition {
102                message: format!("invalid Worker bind address: {error}"),
103            })?;
104        let worker_target = options.worker_target_value().cloned().unwrap_or_else(|| {
105            let host = if bind_address.ip().is_unspecified() {
106                "127.0.0.1".to_string()
107            } else {
108                bind_address.ip().to_string()
109            };
110            WorkerTarget::new(format!("{host}:{}", bind_address.port()))
111        });
112        let endpoint = Endpoint::from_shared(endpoint_address(options.server_address_value()))
113            .map_err(service_error)?;
114        let flow_service = {
115            let _runtime_guard = runtime.enter();
116            FlowServiceClient::new(endpoint.connect_lazy())
117        };
118        let attribute_indexes = registry.attribute_indexes().clone();
119        let hydrator = ValueHydrator::new(flow_service.clone(), blob_cache);
120        let dispatcher = WorkerDispatcher::new(registry, hydrator);
121        let (shutdown, _) = watch::channel(false);
122        Ok(Self {
123            runtime,
124            service: RustWorkerService { dispatcher },
125            flow_service,
126            attribute_indexes,
127            attribute_index_sync_timeout: options.attribute_index_sync_timeout_value(),
128            bind_address,
129            worker_target,
130            shutdown,
131            state: AtomicU8::new(CREATED),
132        })
133    }
134
135    /// Returns the resolved WorkerService target that Clients should advertise to Dex.
136    pub fn worker_target(&self) -> &WorkerTarget {
137        &self.worker_target
138    }
139
140    /// Synchronizes Attribute indexes, serves WorkerService, and blocks until stopped or failed.
141    ///
142    /// # Errors
143    ///
144    /// Returns FlowDefinition if called more than once, or Service for index synchronization,
145    /// timeout, bind, and server failures.
146    pub fn start(&self) -> SdkResult<()> {
147        self.state
148            .compare_exchange(CREATED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
149            .map_err(|_| SdkError::FlowDefinition {
150                message: "Worker can only be started once".to_string(),
151            })?;
152        let mut shutdown = self.shutdown.subscribe();
153        let mut flow_service = self.flow_service.clone();
154        let sync_result = self.runtime.block_on(async {
155            tokio::time::timeout(
156                self.attribute_index_sync_timeout,
157                flow_service.sync_attribute_indexes(SyncAttributeIndexRequest {
158                    attribute_indexes: self.attribute_indexes.clone(),
159                }),
160            )
161            .await
162        });
163        match sync_result {
164            Ok(Ok(_)) => {}
165            Ok(Err(status)) => {
166                self.state.store(STOPPED, Ordering::Release);
167                return Err(service_error(status));
168            }
169            Err(elapsed) => {
170                self.state.store(STOPPED, Ordering::Release);
171                return Err(service_error(elapsed));
172            }
173        }
174        let result = self.runtime.block_on(
175            Server::builder()
176                .add_service(WorkerServiceServer::new(self.service.clone()))
177                .serve_with_shutdown(self.bind_address, async move {
178                    while !*shutdown.borrow() && shutdown.changed().await.is_ok() {}
179                }),
180        );
181        self.state.store(STOPPED, Ordering::Release);
182        result.map_err(service_error)
183    }
184
185    /// Requests shutdown of a running Worker and returns immediately.
186    ///
187    /// Calling `stop` before or after `start` is safe; [`Self::start`] observes the shutdown signal.
188    pub fn stop(&self) {
189        let _ = self.shutdown.send(true);
190    }
191}
192
193#[derive(Clone)]
194struct RustWorkerService {
195    dispatcher: WorkerDispatcher,
196}
197
198#[tonic::async_trait]
199impl WorkerService for RustWorkerService {
200    async fn invoke_wait_for_method(
201        &self,
202        request: Request<InvokeWaitForMethodRequest>,
203    ) -> Result<Response<InvokeWaitForMethodResponse>, Status> {
204        self.dispatcher
205            .invoke_wait_for(request.into_inner())
206            .await
207            .map(Response::new)
208            .map_err(worker_status)
209    }
210
211    async fn invoke_execute_method(
212        &self,
213        request: Request<InvokeExecuteMethodRequest>,
214    ) -> Result<Response<InvokeExecuteMethodResponse>, Status> {
215        self.dispatcher
216            .invoke_execute(request.into_inner())
217            .await
218            .map(Response::new)
219            .map_err(worker_status)
220    }
221
222    async fn invoke_worker_rpc(
223        &self,
224        request: Request<InvokeWorkerRpcRequest>,
225    ) -> Result<Response<InvokeWorkerRpcResponse>, Status> {
226        self.dispatcher
227            .invoke_rpc(request.into_inner())
228            .await
229            .map(Response::new)
230            .map_err(worker_status)
231    }
232}
233
234#[derive(Clone, PartialEq, Message)]
235struct GoogleRpcStatus {
236    #[prost(int32, tag = "1")]
237    code: i32,
238    #[prost(string, tag = "2")]
239    message: String,
240    #[prost(message, repeated, tag = "3")]
241    details: Vec<Any>,
242}
243
244fn worker_status(error: HandlerError) -> Status {
245    let message = error.to_string();
246    let detail = WorkerErrorResponse {
247        detail: message.clone(),
248        error_type: error.error_type().to_string(),
249        stack_trace: String::new(),
250    };
251    let status = GoogleRpcStatus {
252        code: Code::Unknown as i32,
253        message: message.clone(),
254        details: vec![Any {
255            type_url: "type.googleapis.com/dex.WorkerErrorResponse".to_string(),
256            value: detail.encode_to_vec(),
257        }],
258    };
259    Status::with_details(Code::Unknown, message, status.encode_to_vec().into())
260}
261
262fn endpoint_address(address: &str) -> String {
263    if address.contains("://") {
264        address.to_string()
265    } else {
266        format!("http://{address}")
267    }
268}
269
270fn service_error(error: impl std::fmt::Display) -> SdkError {
271    SdkError::Service {
272        service: crate::ServiceError::local("worker", error.to_string()),
273    }
274}