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::{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}