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}