Skip to main content

dex_sdk/
pull_registration.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::BTreeMap;
10use std::fmt::{Display, Formatter};
11use std::sync::Mutex;
12
13use tokio::sync::watch;
14use tonic::Status;
15
16use crate::{SdkError, SdkResult};
17
18/// Permanent rejection that leaves a required pull slot kind without any registered stream.
19///
20/// Use [`SdkError::pull_registration_error`] on errors from [`crate::Worker::wait_pull_registered`] or
21/// [`crate::Worker::start`] to inspect the slot kind and original gRPC status.
22///
23/// ```
24/// fn report(error: dex_sdk::SdkError) {
25///     if let Some(rejection) = error.pull_registration_error() {
26///         eprintln!("queue {}: {}", rejection.queue, rejection.status.code());
27///     }
28/// }
29/// ```
30#[derive(Clone, Debug)]
31pub struct PullRegistrationError {
32    /// Slot kind label: `STEP_AND_RPC` or `RPC_ONLY`; retained field name for source compatibility.
33    pub queue: String,
34    /// Original permanent gRPC rejection, preserving its code and details.
35    pub status: Status,
36}
37
38impl Display for PullRegistrationError {
39    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
40        write!(
41            formatter,
42            "pull slot kind {} rejected: {}",
43            self.queue, self.status
44        )
45    }
46}
47
48impl std::error::Error for PullRegistrationError {
49    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50        Some(&self.status)
51    }
52}
53
54/// Independent snapshot of one slot kind's registration state.
55#[derive(Clone, Debug)]
56pub struct PullQueueRegistrationState {
57    /// Slot kind label: `STEP_AND_RPC` or `RPC_ONLY`; retained field name for source compatibility.
58    pub queue: String,
59    /// Whether this slot kind currently has an acknowledged stream.
60    pub has_registered_stream: bool,
61    /// Last permanent rejection, cleared when a stream successfully registers.
62    pub last_permanent_error: Option<Status>,
63}
64
65/// Independent readiness snapshot returned by [`crate::Worker::pull_registration_state`].
66#[derive(Clone, Debug, Default)]
67pub struct PullRegistrationState {
68    /// True after a Step-and-RPC slot and, when reserved, an RPC-only slot register once.
69    /// A permanent rejection with no registered stream of that kind clears readiness.
70    pub is_ready: bool,
71    /// Slot-kind snapshots sorted by label; the field name is retained for source compatibility.
72    pub queues: Vec<PullQueueRegistrationState>,
73}
74
75#[derive(Default)]
76struct QueueRegistration {
77    has_registered: bool,
78    registered_streams: usize,
79    last_permanent_error: Option<Status>,
80}
81
82#[derive(Default)]
83struct RegistrationState {
84    queues: BTreeMap<String, QueueRegistration>,
85    has_registered_all_queues: bool,
86    initial_error: Option<SdkError>,
87    permanent_error: Option<PullRegistrationError>,
88    has_stopped: bool,
89}
90
91pub(crate) struct PullRegistration {
92    state: Mutex<RegistrationState>,
93    changed: watch::Sender<()>,
94}
95
96impl Default for PullRegistration {
97    fn default() -> Self {
98        Self::new(&[])
99    }
100}
101
102impl PullRegistration {
103    pub(crate) fn new(queues: &[String]) -> Self {
104        Self {
105            state: Mutex::new(RegistrationState {
106                queues: queues
107                    .iter()
108                    .map(|queue| (queue.clone(), QueueRegistration::default()))
109                    .collect(),
110                ..RegistrationState::default()
111            }),
112            changed: watch::channel(()).0,
113        }
114    }
115
116    pub(crate) fn registered(&self, queue: &str) {
117        let mut state = self.state.lock().expect("pull registration lock");
118        let registration = state.queues.entry(queue.to_string()).or_default();
119        registration.registered_streams += 1;
120        registration.has_registered = true;
121        registration.last_permanent_error = None;
122        state.has_registered_all_queues |= state.queues.values().all(|queue| queue.has_registered);
123        self.changed.send_replace(());
124    }
125
126    pub(crate) fn ended(&self, queue: &str, was_registered: bool, failure: Option<&Status>) {
127        let mut state = self.state.lock().expect("pull registration lock");
128        let registration = state.queues.entry(queue.to_string()).or_default();
129        if was_registered {
130            registration.registered_streams -= 1;
131        }
132        if let Some(status) = failure {
133            registration.last_permanent_error = Some(status.clone());
134            if registration.registered_streams == 0 {
135                let rejection = PullRegistrationError {
136                    queue: queue.to_string(),
137                    status: status.clone(),
138                };
139                if !state.has_registered_all_queues && state.initial_error.is_none() {
140                    state.initial_error = Some(SdkError::from_pull_registration(rejection.clone()));
141                }
142                state.permanent_error.get_or_insert(rejection);
143            }
144        }
145        self.changed.send_replace(());
146    }
147
148    pub(crate) fn finish(&self, failure: Option<SdkError>) {
149        let mut state = self.state.lock().expect("pull registration lock");
150        if state.initial_error.is_none() {
151            state.initial_error = failure;
152        }
153        state.has_stopped = true;
154        self.changed.send_replace(());
155    }
156
157    pub(crate) fn permanent_error(&self) -> Option<PullRegistrationError> {
158        self.state
159            .lock()
160            .expect("pull registration lock")
161            .permanent_error
162            .clone()
163    }
164
165    pub(crate) fn snapshot(&self) -> PullRegistrationState {
166        let state = self.state.lock().expect("pull registration lock");
167        let queues: Vec<_> = state
168            .queues
169            .iter()
170            .map(|(queue, registration)| PullQueueRegistrationState {
171                queue: queue.clone(),
172                has_registered_stream: registration.registered_streams > 0,
173                last_permanent_error: registration.last_permanent_error.clone(),
174            })
175            .collect();
176        PullRegistrationState {
177            is_ready: state.has_registered_all_queues
178                && !queues.iter().any(|queue| {
179                    !queue.has_registered_stream && queue.last_permanent_error.is_some()
180                }),
181            queues,
182        }
183    }
184
185    pub(crate) async fn wait(&self) -> SdkResult<()> {
186        let mut changed = self.changed.subscribe();
187        loop {
188            {
189                let state = self.state.lock().expect("pull registration lock");
190                if state.has_registered_all_queues {
191                    return Ok(());
192                }
193                if let Some(error) = &state.initial_error {
194                    return Err(error.clone());
195                }
196                if state.has_stopped {
197                    return Err(SdkError::InvalidArgument {
198                        message: "pull Worker stopped before registration".to_string(),
199                    });
200                }
201            }
202            changed
203                .changed()
204                .await
205                .expect("registration sender remains alive");
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[tokio::test]
215    async fn readiness_latches_and_recovers_from_permanent_rejections() {
216        let registration = PullRegistration::new(&["app".to_string(), "flow".to_string()]);
217        registration.registered("app");
218        assert!(!registration.snapshot().is_ready);
219        registration.registered("flow");
220        registration.wait().await.expect("all queues registered");
221        let snapshot = registration.snapshot();
222        registration.ended("flow", true, None);
223        assert!(registration.snapshot().is_ready);
224        registration.ended("flow", false, Some(&Status::permission_denied("rejected")));
225        assert!(!registration.snapshot().is_ready);
226        registration
227            .wait()
228            .await
229            .expect("completion remains latched");
230        registration.registered("flow");
231        assert!(registration.snapshot().is_ready);
232        assert!(
233            registration.snapshot().queues[1]
234                .last_permanent_error
235                .is_none()
236        );
237        registration.finish(None);
238        registration.ended("app", true, None);
239        registration.ended("flow", true, None);
240        assert!(registration.snapshot().is_ready);
241        assert!(
242            snapshot
243                .queues
244                .iter()
245                .all(|queue| queue.has_registered_stream)
246        );
247    }
248
249    #[tokio::test]
250    async fn another_registered_stream_prevents_a_fatal_rejection() {
251        let registration = PullRegistration::new(&["app".to_string(), "flow".to_string()]);
252        registration.registered("app");
253        registration.ended("app", false, Some(&Status::invalid_argument("rejected")));
254        assert!(registration.permanent_error().is_none());
255        registration.registered("flow");
256        registration.wait().await.expect("all queues registered");
257        assert!(registration.snapshot().is_ready);
258        registration.ended("app", true, None);
259        assert!(!registration.snapshot().is_ready);
260        registration.registered("app");
261        assert!(registration.snapshot().is_ready);
262    }
263
264    #[tokio::test]
265    async fn stopping_before_readiness_wakes_all_waiters() {
266        let registration = std::sync::Arc::new(PullRegistration::new(&["app".to_string()]));
267        let first = std::sync::Arc::clone(&registration);
268        let second = std::sync::Arc::clone(&registration);
269        let waiting_first = tokio::spawn(async move { first.wait().await });
270        let waiting_second = tokio::spawn(async move { second.wait().await });
271        registration.finish(None);
272        assert!(waiting_first.await.expect("first waiter").is_err());
273        assert!(waiting_second.await.expect("second waiter").is_err());
274    }
275}