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::{BTreeSet, HashMap};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU8, Ordering};
12
13use crate::client::flow_service_tls::flow_service_endpoint;
14use crate::credential::{CredentialChannel, CredentialInterceptor};
15use dex_protocol::dex::SyncAttributeIndexRequest;
16use dex_protocol::dex::flow_service_client::FlowServiceClient;
17use tokio::runtime::Runtime;
18use tokio::sync::watch;
19
20use crate::flow_definition::{collect_flow_definitions, upload_flow_definitions_at_worker_start};
21use crate::server_protocol::{NegotiatedServerProtocolVersion, negotiate_server_protocol};
22use crate::value_hydrator::ValueHydrator;
23use crate::worker_dispatcher::WorkerDispatcher;
24use crate::worker_pull::PullWorker;
25use crate::{BlobCache, FlowDefinitionDocument, Registry, SdkError, SdkResult, WorkerOptions};
26
27const CREATED: u8 = 0;
28const RUNNING: u8 = 1;
29const STOPPED: u8 = 2;
30
31/// Runs registered Flow Step and RPC handlers through outbound task streams.
32///
33/// A Worker owns an internal Tokio runtime and is one-shot. [`Self::start`] negotiates the Server
34/// protocol, synchronizes Attribute indexes, then blocks while serving. Call [`Self::stop`]
35/// from another thread to request graceful shutdown. The Worker runs at most the configured number
36/// of handlers concurrently and needs only an outbound connection to Dex. See [`crate::PullWorkerOptions`].
37///
38/// # Examples
39///
40/// ```no_run
41/// use dex_sdk::{BlobCache, BlobCacheConfig, Registry, Worker, WorkerOptions};
42/// use std::sync::Arc;
43///
44/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
45/// "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
46/// )?)?);
47/// let worker = Worker::try_new(Registry::new(), cache, WorkerOptions::new())?;
48/// worker.start()?;
49/// # Ok::<(), Box<dyn std::error::Error>>(())
50/// ```
51pub struct Worker {
52 runtime: Runtime,
53 flow_service: FlowServiceClient<CredentialChannel>,
54 attribute_indexes: HashMap<String, i32>,
55 flow_types: BTreeSet<String>,
56 flow_definitions: Vec<FlowDefinitionDocument>,
57 attribute_index_sync_timeout: std::time::Duration,
58 pull: PullWorker,
59 shutdown: watch::Sender<bool>,
60 state: AtomicU8,
61 negotiated_protocol_version: NegotiatedServerProtocolVersion,
62}
63
64impl Worker {
65 /// Creates a Worker or panics when options or runtime initialization are invalid.
66 ///
67 /// # Panics
68 ///
69 /// Panics for invalid pull options, zero index-sync timeout, invalid Dex endpoint, or Tokio
70 /// runtime creation failure. Invalid TLS settings also panic. Prefer [`Self::try_new`] for recoverable startup.
71 pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: WorkerOptions) -> Self {
72 Self::try_new(registry, blob_cache, options)
73 .unwrap_or_else(|error| panic!("cannot create Rust Worker: {error}"))
74 }
75
76 /// Creates a one-shot pull Worker with a lazy outbound connection.
77 ///
78 /// # Errors
79 ///
80 /// Returns [`SdkError::FlowDefinition`] for invalid Worker options, or Service for runtime
81 /// and endpoint initialization failures. Invalid TLS options return [`SdkError::InvalidArgument`].
82 /// The connection is lazy; certificate and handshake failures occur during [`Self::start`].
83 /// Changed in 1.45.0: this method reads the configured Flow definition files. It returns
84 /// [`SdkError::FlowDefinition`] when two Flow definition sources share a Flow type, or an
85 /// in-memory document is not a Flow Definition Graph 2.x naming its Flow type, or a
86 /// Flow definition path is missing or invalid.
87 pub fn try_new(
88 registry: Registry,
89 blob_cache: Arc<BlobCache>,
90 mut options: WorkerOptions,
91 ) -> SdkResult<Self> {
92 let (flow_definition_paths, flow_definition_documents) =
93 options.take_flow_definition_sources();
94 let flow_definitions =
95 collect_flow_definitions(flow_definition_paths, flow_definition_documents)?;
96 let runtime = Runtime::new().map_err(service_error)?;
97 if options.attribute_index_sync_timeout_value().is_zero() {
98 return Err(SdkError::FlowDefinition {
99 message: "attribute index sync timeout must be positive".to_string(),
100 });
101 }
102 let endpoint = flow_service_endpoint(
103 options.server_address_value(),
104 options.flow_service_tls_value(),
105 "worker",
106 )?;
107 let flow_service = {
108 let _runtime_guard = runtime.enter();
109 FlowServiceClient::new(CredentialChannel::new(
110 endpoint.connect_lazy(),
111 CredentialInterceptor::new(options.credential_value()),
112 ))
113 };
114 let pull_channel = {
115 let _runtime_guard = runtime.enter();
116 crate::worker_pull::outbound_task_channel(endpoint.clone(), options.credential_value())
117 };
118 let attribute_indexes = registry.attribute_indexes().clone();
119 let flow_types = registry.flow_types().into_iter().collect();
120 let hydrator = ValueHydrator::new(flow_service.clone(), blob_cache);
121 let negotiated_protocol_version = NegotiatedServerProtocolVersion::default();
122 let dispatcher =
123 WorkerDispatcher::new(registry, hydrator, negotiated_protocol_version.clone());
124 let pull = PullWorker::new(options.pull_value(), &dispatcher, pull_channel)?;
125 let (shutdown, _) = watch::channel(false);
126 Ok(Self {
127 runtime,
128 flow_service,
129 attribute_indexes,
130 flow_types,
131 flow_definitions,
132 attribute_index_sync_timeout: options.attribute_index_sync_timeout_value(),
133 pull,
134 shutdown,
135 state: AtomicU8::new(CREATED),
136 negotiated_protocol_version,
137 })
138 }
139
140 /// Negotiates protocol, synchronizes Attribute indexes, and serves until stopped or failed.
141 ///
142 /// Changed in 1.23.0: busy streams send transport keepalives during handler execution.
143 /// These preserve the connection; handlers must still record progress within their heartbeat timeout.
144 ///
145 /// Changed in 1.45.0: after Attribute-index synchronization and before opening task streams,
146 /// the Worker uploads the Flow definitions configured with
147 /// [`WorkerOptions::flow_definition_paths`] and [`WorkerOptions::flow_definitions`]. An upload
148 /// failure is logged as a warning and does not stop the Worker.
149 ///
150 /// Stream errors are logged and retried. After [`Self::stop`], this method drains
151 /// active handlers for the configured timeout, cancels any remaining handlers, and waits for
152 /// them to return. Call this method only once, on a dedicated thread.
153 ///
154 /// # Errors
155 ///
156 /// Returns FlowDefinition if called more than once, or Service for index synchronization,
157 /// timeout, incompatible Server protocol, and server failures. An unreachable Server returns
158 /// the Service error carrying the original gRPC status code, such as `UNAVAILABLE`.
159 /// Startup also fails with a Service error whose code is `FAILED_PRECONDITION` when
160 /// `max_concurrent_tasks` exceeds the Server setting `matching.maxConcurrentTasksCap`. The
161 /// first registered stream reports that setting; the Worker then half-closes its streams.
162 /// Returns a service error exposing [`SdkError::pull_registration_error`] after draining when permanent-rejection shutdown is enabled.
163 pub fn start(&self) -> SdkResult<()> {
164 let result = self.start_worker();
165 self.pull
166 .registration
167 .finish(result.as_ref().err().cloned());
168 result
169 }
170
171 fn start_worker(&self) -> SdkResult<()> {
172 self.state
173 .compare_exchange(CREATED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
174 .map_err(|_| SdkError::FlowDefinition {
175 message: "Worker can only be started once".to_string(),
176 })?;
177 let shutdown = self.shutdown.subscribe();
178 let mut flow_service = self.flow_service.clone();
179 let server_info_result = self.runtime.block_on(async {
180 tokio::time::timeout(
181 self.attribute_index_sync_timeout,
182 flow_service.get_server_info(()),
183 )
184 .await
185 });
186 let server_info = match server_info_result {
187 Ok(Ok(response)) => response.into_inner(),
188 Ok(Err(status)) => {
189 self.state.store(STOPPED, Ordering::Release);
190 return Err(SdkError::from_status(
191 status,
192 "get_server_info",
193 None,
194 crate::sdk_error::FlowTargetRequirement::None,
195 ));
196 }
197 Err(elapsed) => {
198 self.state.store(STOPPED, Ordering::Release);
199 return Err(service_error(format!("GetServerInfo: {elapsed}")));
200 }
201 };
202 let negotiated_protocol_version =
203 negotiate_server_protocol(&server_info).map_err(|error| {
204 self.state.store(STOPPED, Ordering::Release);
205 service_error(error)
206 })?;
207 self.negotiated_protocol_version
208 .store(negotiated_protocol_version);
209 let sync_result = self.runtime.block_on(async {
210 tokio::time::timeout(
211 self.attribute_index_sync_timeout,
212 flow_service.sync_attribute_indexes(SyncAttributeIndexRequest {
213 attribute_indexes: self.attribute_indexes.clone(),
214 }),
215 )
216 .await
217 });
218 match sync_result {
219 Ok(Ok(_)) => {}
220 Ok(Err(status)) => {
221 self.state.store(STOPPED, Ordering::Release);
222 return Err(service_error(status));
223 }
224 Err(elapsed) => {
225 self.state.store(STOPPED, Ordering::Release);
226 return Err(service_error(elapsed));
227 }
228 }
229 if !self.flow_definitions.is_empty() {
230 let mut upload_shutdown = self.shutdown.subscribe();
231 self.runtime.block_on(async {
232 // Stop cancels an in-flight upload by dropping its future.
233 tokio::select! {
234 () = upload_flow_definitions_at_worker_start(
235 &flow_service,
236 self.attribute_index_sync_timeout,
237 &self.flow_definitions,
238 &self.flow_types,
239 ) => {}
240 _ = upload_shutdown.wait_for(|is_stopped| *is_stopped) => {}
241 }
242 });
243 }
244 let result = self.runtime.block_on(self.pull.run(shutdown));
245 self.state.store(STOPPED, Ordering::Release);
246 result
247 }
248
249 /// Waits until a Step-and-RPC slot and, when reserved, an RPC-only slot have registered once.
250 ///
251 /// Safe before or after [`Self::start`] and for concurrent callers. Start the Worker on a
252 /// dedicated thread. Drop this future to cancel the wait, or use `tokio::time::timeout`.
253 /// Completion remains latched after shutdown; use [`Self::pull_registration_state`] for probes.
254 ///
255 /// # Errors
256 ///
257 /// Returns a service error exposing [`SdkError::pull_registration_error`] for a permanent rejection without another registered
258 /// stream before readiness. Returns the startup error if startup fails, or InvalidArgument
259 /// for shutdown before readiness.
260 /// When `max_concurrent_tasks` exceeds the Server setting `matching.maxConcurrentTasksCap`,
261 /// readiness is never reported and this returns the same `FAILED_PRECONDITION` Service error
262 /// as [`Self::start`].
263 ///
264 /// ```no_run
265 /// # async fn ready(worker: &dex_sdk::Worker) -> Result<(), Box<dyn std::error::Error>> {
266 /// tokio::time::timeout(std::time::Duration::from_secs(10), worker.wait_pull_registered()).await??;
267 /// assert!(worker.pull_registration_state().is_ready);
268 /// # Ok(()) }
269 /// ```
270 pub async fn wait_pull_registered(&self) -> SdkResult<()> {
271 self.pull.registration.wait().await
272 }
273
274 /// Returns zero for the obsolete reserved-RPC decline counter.
275 ///
276 /// Deprecated: the Server assigns work to reserved slots, and the Worker never declines tasks
277 /// for capacity. This method remains callable after shutdown and is safe for concurrent reads.
278 /// Remove calls to it; existing callers keep compiling with a deprecation warning.
279 ///
280 /// ```no_run
281 /// # fn inspect(worker: &dex_sdk::Worker) {
282 /// #[expect(deprecated, reason = "the counter is kept for source compatibility")]
283 /// let declines: u64 = worker.pull_reserved_rpc_declines();
284 /// assert_eq!(declines, 0);
285 /// # }
286 /// ```
287 #[deprecated(note = "reserved slots never decline for capacity; this counter is always zero")]
288 pub fn pull_reserved_rpc_declines(&self) -> u64 {
289 0
290 }
291
292 /// Returns an independent snapshot of current pull slot-kind registrations.
293 ///
294 /// Readiness latches after each required slot kind registers once. Only a permanent rejection with no
295 /// registered stream makes it false. Successful registration clears that slot kind's error.
296 /// Transient disconnects and Stop do not clear readiness. See [`Self::wait_pull_registered`] for a startup and probe example.
297 pub fn pull_registration_state(&self) -> crate::PullRegistrationState {
298 self.pull.registration.snapshot()
299 }
300
301 /// Requests shutdown of a running Worker and returns immediately.
302 ///
303 /// Calling `stop` before or after `start` is safe; [`Self::start`] observes the shutdown signal.
304 /// No new handler starts after this request. Idle streams half-close immediately; busy streams
305 /// finish their handler and send its final output before half-closing. Task frames arriving
306 /// after Stop are dropped without invoking a handler, including after half-close. Streams keep
307 /// reading until the Server ends them or the drain deadline expires. At that deadline, busy
308 /// streams whose final output is not yet queued are reset and their handlers are cancelled;
309 /// [`Self::start`] waits for their return. Dex treats a task on a reset stream as one that may
310 /// have run. A busy stream whose final output is already queued still sends it and half-closes.
311 /// A stream whose last final output Dex has not yet acknowledged with a new task waits up to
312 /// five seconds past the drain deadline for the Server to end it, even after its handler has
313 /// returned. [`Self::start`] can therefore return up to five seconds after the drain deadline.
314 /// Every other stream still open at the deadline, including one blocked on a send, is reset.
315 /// Stop is idempotent.
316 /// Changed in 1.35.0: tasks received after Stop are returned without running or consuming a
317 /// retry. Draining preserves final output before the stream closes. Use this method for
318 /// graceful shutdown and join the thread running [`Self::start`] before releasing resources.
319 pub fn stop(&self) {
320 self.pull.stop_accepting_tasks();
321 self.pull.registration.finish(None);
322 self.shutdown.send_replace(true);
323 }
324}
325
326fn service_error(error: impl std::fmt::Display) -> SdkError {
327 SdkError::Service {
328 service: crate::ServiceError::local("worker", error.to_string()),
329 }
330}