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