Skip to main content

dex_sdk/
worker.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use 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-Sustainable-Use-1.0
8
9use std::collections::HashMap;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU8, Ordering};
12
13use crate::credential::{CredentialChannel, CredentialInterceptor};
14use dex_protocol::dex::SyncAttributeIndexRequest;
15use dex_protocol::dex::flow_service_client::FlowServiceClient;
16use tokio::runtime::Runtime;
17use tokio::sync::watch;
18use tonic::transport::Endpoint;
19
20use crate::server_protocol::{
21    NegotiatedServerProtocolVersion, negotiate_server_protocol, server_info_request_error,
22};
23use crate::value_hydrator::ValueHydrator;
24use crate::worker_dispatcher::WorkerDispatcher;
25use crate::worker_pull::PullWorker;
26use crate::{BlobCache, Registry, SdkError, SdkResult, WorkerOptions};
27
28const CREATED: u8 = 0;
29const RUNNING: u8 = 1;
30const STOPPED: u8 = 2;
31
32/// Runs registered Flow Step and RPC handlers through outbound task streams.
33///
34/// A Worker owns an internal Tokio runtime and is one-shot. [`Self::start`] negotiates the Server
35/// protocol, synchronizes Attribute indexes, then blocks while serving. Call [`Self::stop`]
36/// from another thread to request graceful shutdown. The Worker runs at most the configured number
37/// of handlers concurrently and needs only an outbound connection to Dex. See [`crate::PullWorkerOptions`].
38///
39/// # Examples
40///
41/// ```no_run
42/// use dex_sdk::{BlobCache, BlobCacheConfig, Registry, Worker, WorkerOptions};
43/// use std::sync::Arc;
44///
45/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
46///     "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
47/// )?)?);
48/// let worker = Worker::try_new(Registry::new(), cache, WorkerOptions::new())?;
49/// worker.start()?;
50/// # Ok::<(), Box<dyn std::error::Error>>(())
51/// ```
52pub struct Worker {
53    runtime: Runtime,
54    flow_service: FlowServiceClient<CredentialChannel>,
55    attribute_indexes: HashMap<String, i32>,
56    attribute_index_sync_timeout: std::time::Duration,
57    pull: PullWorker,
58    shutdown: watch::Sender<bool>,
59    state: AtomicU8,
60    negotiated_protocol_version: NegotiatedServerProtocolVersion,
61}
62
63impl Worker {
64    /// Creates a Worker or panics when options or runtime initialization are invalid.
65    ///
66    /// # Panics
67    ///
68    /// Panics for invalid pull options, zero index-sync timeout, invalid Dex endpoint, or Tokio
69    /// runtime creation failure. Prefer [`Self::try_new`] for recoverable startup.
70    pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: WorkerOptions) -> Self {
71        Self::try_new(registry, blob_cache, options)
72            .unwrap_or_else(|error| panic!("cannot create Rust Worker: {error}"))
73    }
74
75    /// Creates a one-shot pull Worker with a lazy outbound connection.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`SdkError::FlowDefinition`] for invalid Worker options, or Service for runtime
80    /// and endpoint initialization failures. The Dex connection is lazy.
81    pub fn try_new(
82        registry: Registry,
83        blob_cache: Arc<BlobCache>,
84        options: WorkerOptions,
85    ) -> SdkResult<Self> {
86        let runtime = Runtime::new().map_err(service_error)?;
87        if options.attribute_index_sync_timeout_value().is_zero() {
88            return Err(SdkError::FlowDefinition {
89                message: "attribute index sync timeout must be positive".to_string(),
90            });
91        }
92        let endpoint = Endpoint::from_shared(endpoint_address(options.server_address_value()))
93            .map_err(service_error)?;
94        let flow_service = {
95            let _runtime_guard = runtime.enter();
96            FlowServiceClient::new(CredentialChannel::new(
97                endpoint.connect_lazy(),
98                CredentialInterceptor::new(options.credential_value()),
99            ))
100        };
101        let pull_channel = {
102            let _runtime_guard = runtime.enter();
103            crate::worker_pull::outbound_task_channel(endpoint.clone(), options.credential_value())
104        };
105        let attribute_indexes = registry.attribute_indexes().clone();
106        let hydrator = ValueHydrator::new(flow_service.clone(), blob_cache);
107        let negotiated_protocol_version = NegotiatedServerProtocolVersion::default();
108        let dispatcher =
109            WorkerDispatcher::new(registry, hydrator, negotiated_protocol_version.clone());
110        let pull = PullWorker::new(options.pull_value(), &dispatcher, pull_channel)?;
111        let (shutdown, _) = watch::channel(false);
112        Ok(Self {
113            runtime,
114            flow_service,
115            attribute_indexes,
116            attribute_index_sync_timeout: options.attribute_index_sync_timeout_value(),
117            pull,
118            shutdown,
119            state: AtomicU8::new(CREATED),
120            negotiated_protocol_version,
121        })
122    }
123
124    /// Negotiates protocol, synchronizes Attribute indexes, and serves until stopped or failed.
125    ///
126    /// Stream errors are logged and retried. After [`Self::stop`], this method drains
127    /// active handlers for the configured timeout, cancels any remaining handlers, and waits for
128    /// them to return. Call this method only once, on a dedicated thread.
129    ///
130    /// # Errors
131    ///
132    /// Returns FlowDefinition if called more than once, or Service for index synchronization,
133    /// timeout, and server failures.
134    /// Returns a service error exposing [`SdkError::pull_registration_error`] after draining when permanent-rejection shutdown is enabled.
135    pub fn start(&self) -> SdkResult<()> {
136        let result = self.start_worker();
137        self.pull
138            .registration
139            .finish(result.as_ref().err().cloned());
140        result
141    }
142
143    fn start_worker(&self) -> SdkResult<()> {
144        self.state
145            .compare_exchange(CREATED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
146            .map_err(|_| SdkError::FlowDefinition {
147                message: "Worker can only be started once".to_string(),
148            })?;
149        let shutdown = self.shutdown.subscribe();
150        let mut flow_service = self.flow_service.clone();
151        let server_info_result = self.runtime.block_on(async {
152            tokio::time::timeout(
153                self.attribute_index_sync_timeout,
154                flow_service.get_server_info(()),
155            )
156            .await
157        });
158        let server_info = match server_info_result {
159            Ok(Ok(response)) => response.into_inner(),
160            Ok(Err(status)) => {
161                self.state.store(STOPPED, Ordering::Release);
162                return Err(service_error(server_info_request_error(status)));
163            }
164            Err(elapsed) => {
165                self.state.store(STOPPED, Ordering::Release);
166                return Err(service_error(server_info_request_error(elapsed)));
167            }
168        };
169        let negotiated_protocol_version =
170            negotiate_server_protocol(&server_info).map_err(|error| {
171                self.state.store(STOPPED, Ordering::Release);
172                service_error(error)
173            })?;
174        self.negotiated_protocol_version
175            .store(negotiated_protocol_version);
176        let sync_result = self.runtime.block_on(async {
177            tokio::time::timeout(
178                self.attribute_index_sync_timeout,
179                flow_service.sync_attribute_indexes(SyncAttributeIndexRequest {
180                    attribute_indexes: self.attribute_indexes.clone(),
181                }),
182            )
183            .await
184        });
185        match sync_result {
186            Ok(Ok(_)) => {}
187            Ok(Err(status)) => {
188                self.state.store(STOPPED, Ordering::Release);
189                return Err(service_error(status));
190            }
191            Err(elapsed) => {
192                self.state.store(STOPPED, Ordering::Release);
193                return Err(service_error(elapsed));
194            }
195        }
196        let result = self.runtime.block_on(self.pull.run(shutdown));
197        self.state.store(STOPPED, Ordering::Release);
198        result
199    }
200
201    /// Waits until every Flow type queue and the app Worker ID queue has registered once.
202    ///
203    /// Safe before or after [`Self::start`] and for concurrent callers. Start the Worker on a
204    /// dedicated thread. Drop this future to cancel the wait, or use `tokio::time::timeout`.
205    /// Completion remains latched after shutdown; use [`Self::pull_registration_state`] for probes.
206    ///
207    /// # Errors
208    ///
209    /// Returns a service error exposing [`SdkError::pull_registration_error`] for a permanent rejection without another registered
210    /// stream before readiness. Returns the startup error if startup fails, or InvalidArgument
211    /// for shutdown before readiness.
212    ///
213    /// ```no_run
214    /// # async fn ready(worker: &dex_sdk::Worker) -> Result<(), Box<dyn std::error::Error>> {
215    /// tokio::time::timeout(std::time::Duration::from_secs(10), worker.wait_pull_registered()).await??;
216    /// assert!(worker.pull_registration_state().is_ready);
217    /// # Ok(()) }
218    /// ```
219    pub async fn wait_pull_registered(&self) -> SdkResult<()> {
220        self.pull.registration.wait().await
221    }
222
223    /// Returns cumulative Step declines caused by reserved RPC capacity for this Worker.
224    ///
225    /// The counter counts decline events. Dex may decline the same Step dispatch repeatedly
226    /// while retrying it, so read the counter as a rate. Full-capacity rejections and unknown names are excluded. The count never resets and
227    /// remains available after stop. Concurrent reads are safe.
228    /// Export the count to a monitoring counter to identify reserve pressure.
229    ///
230    /// ```no_run
231    /// # fn inspect(worker: &dex_sdk::Worker) {
232    /// let declines: u64 = worker.pull_reserved_rpc_declines();
233    /// # }
234    /// ```
235    pub fn pull_reserved_rpc_declines(&self) -> u64 {
236        self.pull.reserved_rpc_declines()
237    }
238
239    /// Returns an independent snapshot of current pull queue registrations.
240    ///
241    /// Readiness latches after every queue registers once. Only a permanent rejection with no
242    /// registered stream makes it false. Successful registration clears that queue's error.
243    /// Transient disconnects and Stop do not clear readiness. See [`Self::wait_pull_registered`] for a startup and probe example.
244    pub fn pull_registration_state(&self) -> crate::PullRegistrationState {
245        self.pull.registration.snapshot()
246    }
247
248    /// Requests shutdown of a running Worker and returns immediately.
249    ///
250    /// Calling `stop` before or after `start` is safe; [`Self::start`] observes the shutdown signal.
251    /// The Worker first drains active handlers. After the drain timeout it cancels them, and
252    /// [`Self::start`] waits for their return. Half-closed pull streams wait for server EOF
253    /// until the remaining drain deadline. Stop is idempotent.
254    pub fn stop(&self) {
255        self.pull.registration.finish(None);
256        self.shutdown.send_replace(true);
257    }
258}
259
260fn endpoint_address(address: &str) -> String {
261    if address.contains("://") {
262        address.to_string()
263    } else {
264        format!("http://{address}")
265    }
266}
267
268fn service_error(error: impl std::fmt::Display) -> SdkError {
269    SdkError::Service {
270        service: crate::ServiceError::local("worker", error.to_string()),
271    }
272}