Skip to main content

dex_sdk/
stream.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::marker::PhantomData;
10use std::sync::{Arc, Weak};
11use std::time::{Duration, SystemTime};
12
13use dex_protocol::dex::StepStreamWrite;
14use tokio::sync::Mutex;
15use tokio::task::JoinHandle;
16
17use crate::context::{InvocationCancellation, StepOutputFinalizer};
18use crate::worker_output::StepOutputEmitter;
19use crate::{Context, HandlerError, HandlerResult, Value, value_mapper};
20
21const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
22const DEFAULT_MAX_BUFFERED_BYTES: usize = 16 * 1024;
23
24#[derive(Debug)]
25struct StreamDefinition {
26    name: String,
27    stream_capacity_bytes: i64,
28}
29
30/// Defines a typed best-effort resumable message Stream owned by one Flow type.
31///
32/// Register the same definition in exactly one [`crate::PersistenceSchema`]. Clones share
33/// identity and may be used at Client and Step call sites.
34#[derive(Clone, Debug)]
35pub struct Stream<T> {
36    definition: Arc<StreamDefinition>,
37    marker: PhantomData<fn() -> T>,
38}
39
40impl<T> Stream<T> {
41    /// Creates a Stream with a positive approximate budget shared by all instances of its Flow.
42    ///
43    /// # Panics
44    ///
45    /// Panics when `name` is empty or `stream_capacity_bytes` is not positive.
46    pub fn new(name: impl Into<String>, stream_capacity_bytes: i64) -> Self {
47        let name = name.into();
48        assert!(!name.is_empty(), "Stream name must not be empty");
49        assert!(
50            stream_capacity_bytes > 0,
51            "Stream stream_capacity_bytes must be positive"
52        );
53        Self {
54            definition: Arc::new(StreamDefinition {
55                name,
56                stream_capacity_bytes,
57            }),
58            marker: PhantomData,
59        }
60    }
61
62    /// Returns the stable logical Stream name.
63    pub fn name(&self) -> &str {
64        &self.definition.name
65    }
66
67    /// Returns the approximate shared byte budget.
68    pub fn stream_capacity_bytes(&self) -> i64 {
69        self.definition.stream_capacity_bytes
70    }
71
72    /// Appends one message immediately from the current Step execution.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`crate::HandlerError`] for RPC or Flow timeout Contexts, unregistered Streams,
77    /// encoding failures, cancellation, or a closed Worker output stream. Dex storage failures are
78    /// not acknowledged.
79    pub fn write(&self, context: &mut Context, value: T) -> HandlerResult<()>
80    where
81        T: Value,
82    {
83        context.write_stream(self, value)
84    }
85
86    pub(crate) fn identity(&self) -> usize {
87        Arc::as_ptr(&self.definition) as usize
88    }
89}
90
91impl Stream<String> {
92    /// Creates an invocation-managed writer with a one-second interval and 16 KiB threshold.
93    ///
94    /// The Worker flushes remaining text before the handler result or error. The writer preserves
95    /// chunks exactly, ignores empty chunks, and never splits a chunk.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`HandlerError`] unless this registered String Stream is used by an active Step.
100    pub fn buffered_text(&self, context: &mut Context) -> HandlerResult<BufferedTextStream> {
101        self.buffered_text_with_options(context, BufferedTextStreamOptions::default())
102    }
103
104    /// Creates an invocation-managed writer with explicit buffering settings.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`HandlerError`] for invalid settings, an unregistered Stream, cancellation, or a
109    /// Context that does not belong to a Step.
110    pub fn buffered_text_with_options(
111        &self,
112        context: &mut Context,
113        options: BufferedTextStreamOptions,
114    ) -> HandlerResult<BufferedTextStream> {
115        options.validate()?;
116        let (output_emitter, cancellation, runtime) = context.prepare_buffered_text_stream(self)?;
117        let inner = Arc::new(BufferedTextStreamInner {
118            stream: self.clone(),
119            output_emitter,
120            cancellation: cancellation.clone(),
121            runtime: runtime.clone(),
122            options,
123            state: Mutex::new(BufferedTextStreamState::default()),
124        });
125        context.register_step_output_finalizer(inner.clone())?;
126        BufferedTextStreamInner::watch_cancellation(&inner);
127        Ok(BufferedTextStream { inner })
128    }
129}
130
131/// Configures an invocation-managed buffered text Stream writer.
132#[derive(Clone, Copy, Debug)]
133pub struct BufferedTextStreamOptions {
134    flush_interval: Duration,
135    max_buffered_bytes: usize,
136}
137
138impl BufferedTextStreamOptions {
139    /// Creates settings with a positive interval and soft UTF-8 byte threshold.
140    ///
141    /// Validation occurs when the writer is created.
142    pub fn new(flush_interval: Duration, max_buffered_bytes: usize) -> Self {
143        Self {
144            flush_interval,
145            max_buffered_bytes,
146        }
147    }
148
149    /// Returns the one-shot flush interval.
150    pub fn flush_interval(&self) -> Duration {
151        self.flush_interval
152    }
153
154    /// Returns the soft UTF-8 batch threshold.
155    pub fn max_buffered_bytes(&self) -> usize {
156        self.max_buffered_bytes
157    }
158
159    fn validate(&self) -> HandlerResult<()> {
160        if self.flush_interval.is_zero() {
161            return Err(HandlerError::new(
162                "dex_sdk::HandlerError",
163                "Buffered Stream flush interval must be positive",
164            ));
165        }
166        if self.max_buffered_bytes == 0 {
167            return Err(HandlerError::new(
168                "dex_sdk::HandlerError",
169                "Buffered Stream max buffered bytes must be positive",
170            ));
171        }
172        Ok(())
173    }
174}
175
176impl Default for BufferedTextStreamOptions {
177    fn default() -> Self {
178        Self {
179            flush_interval: DEFAULT_FLUSH_INTERVAL,
180            max_buffered_bytes: DEFAULT_MAX_BUFFERED_BYTES,
181        }
182    }
183}
184
185/// Batches text chunks during one Step invocation.
186///
187/// [`Self::write`] blocks only on the Worker's bounded output queue. It does not wait for Dex Stream
188/// Store acknowledgement. The Worker automatically flushes the tail before returning the handler
189/// result or error.
190#[derive(Clone)]
191pub struct BufferedTextStream {
192    inner: Arc<BufferedTextStreamInner>,
193}
194
195impl BufferedTextStream {
196    /// Appends one chunk and flushes after crossing the soft UTF-8 size threshold.
197    ///
198    /// Empty chunks are ignored. The first nonempty chunk starts a one-shot timer.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`HandlerError`] after cancellation, invocation completion, or output failure.
203    pub fn write(&self, chunk: impl AsRef<str>) -> HandlerResult<()> {
204        let chunk = chunk.as_ref();
205        let mut state = self.inner.state.blocking_lock();
206        state.require_open()?;
207        if chunk.is_empty() {
208            return Ok(());
209        }
210        let was_empty = state.buffer.is_empty();
211        state.buffer.push_str(chunk);
212        state.buffered_bytes += chunk.len();
213        if was_empty {
214            self.inner.start_timer(&mut state);
215        }
216        if state.buffered_bytes >= self.inner.options.max_buffered_bytes {
217            self.inner.stop_timer(&mut state);
218            self.inner.flush_locked(&mut state)?;
219        }
220        Ok(())
221    }
222}
223
224struct BufferedTextStreamInner {
225    stream: Stream<String>,
226    output_emitter: StepOutputEmitter,
227    cancellation: InvocationCancellation,
228    runtime: tokio::runtime::Handle,
229    options: BufferedTextStreamOptions,
230    state: Mutex<BufferedTextStreamState>,
231}
232
233#[derive(Default)]
234struct BufferedTextStreamState {
235    buffer: String,
236    buffered_bytes: usize,
237    timer_generation: u64,
238    timer: Option<JoinHandle<()>>,
239    closed: bool,
240    terminal_failure: Option<String>,
241}
242
243impl BufferedTextStreamState {
244    fn require_open(&self) -> HandlerResult<()> {
245        if let Some(failure) = &self.terminal_failure {
246            return Err(HandlerError::new("dex_sdk::HandlerError", failure));
247        }
248        if self.closed {
249            return Err(HandlerError::new(
250                "dex_sdk::HandlerError",
251                "Buffered Stream invocation has finished",
252            ));
253        }
254        Ok(())
255    }
256}
257
258impl BufferedTextStreamInner {
259    fn start_timer(self: &Arc<Self>, state: &mut BufferedTextStreamState) {
260        state.timer_generation += 1;
261        let generation = state.timer_generation;
262        let weak = Arc::downgrade(self);
263        let interval = self.options.flush_interval;
264        state.timer = Some(self.runtime.spawn(async move {
265            tokio::time::sleep(interval).await;
266            if let Some(inner) = weak.upgrade() {
267                inner.flush_from_timer(generation).await;
268            }
269        }));
270    }
271
272    fn stop_timer(&self, state: &mut BufferedTextStreamState) -> Option<JoinHandle<()>> {
273        state.timer_generation += 1;
274        let timer = state.timer.take();
275        if let Some(timer) = &timer {
276            timer.abort();
277        }
278        timer
279    }
280
281    fn flush_locked(&self, state: &mut BufferedTextStreamState) -> HandlerResult<()> {
282        if state.buffer.is_empty() {
283            return Ok(());
284        }
285        let value = std::mem::take(&mut state.buffer);
286        state.buffered_bytes = 0;
287        let write = self.stream_write(value)?;
288        if let Err(failure) = self.output_emitter.send_stream_write(write) {
289            state.terminal_failure = Some(failure.to_string());
290            return Err(failure);
291        }
292        Ok(())
293    }
294
295    async fn flush_from_timer(&self, generation: u64) {
296        let mut state = self.state.lock().await;
297        if state.closed
298            || state.terminal_failure.is_some()
299            || generation != state.timer_generation
300            || self.cancellation.is_cancelled()
301        {
302            return;
303        }
304        state.timer = None;
305        if state.buffer.is_empty() {
306            return;
307        }
308        let value = std::mem::take(&mut state.buffer);
309        state.buffered_bytes = 0;
310        let write = match self.stream_write(value) {
311            Ok(write) => write,
312            Err(failure) => {
313                state.terminal_failure = Some(failure.to_string());
314                return;
315            }
316        };
317        if let Err(failure) = self.output_emitter.send_stream_write_async(write).await {
318            state.terminal_failure = Some(failure.to_string());
319        }
320    }
321
322    fn stream_write(&self, value: String) -> HandlerResult<StepStreamWrite> {
323        Ok(StepStreamWrite {
324            stream_name: self.stream.name().to_string(),
325            stream_capacity_bytes: self.stream.stream_capacity_bytes(),
326            value: Some(value_mapper::encode_handler(&value)?),
327        })
328    }
329
330    fn watch_cancellation(this: &Arc<Self>) {
331        let weak: Weak<Self> = Arc::downgrade(this);
332        let cancellation = this.cancellation.clone();
333        this.runtime.spawn(async move {
334            cancellation.cancelled().await;
335            if let Some(inner) = weak.upgrade() {
336                inner.cancel().await;
337            }
338        });
339    }
340
341    async fn cancel(&self) {
342        let mut state = self.state.lock().await;
343        state.closed = true;
344        self.stop_timer(&mut state);
345        state.buffer.clear();
346        state.buffered_bytes = 0;
347    }
348}
349
350impl StepOutputFinalizer for BufferedTextStreamInner {
351    fn finalize_step_output(&self) -> HandlerResult<()> {
352        let timer = {
353            let mut state = self.state.blocking_lock();
354            if state.closed {
355                return Ok(());
356            }
357            state.closed = true;
358            self.stop_timer(&mut state)
359        };
360        if let Some(timer) = timer
361            && let Err(failure) = self.runtime.block_on(timer)
362            && !failure.is_cancelled()
363        {
364            return Err(HandlerError::new(
365                "dex_sdk::HandlerError",
366                format!("Buffered Stream timer failed: {failure}"),
367            ));
368        }
369        let mut state = self.state.blocking_lock();
370        if let Some(failure) = &state.terminal_failure {
371            return Err(HandlerError::new("dex_sdk::HandlerError", failure));
372        }
373        self.flush_locked(&mut state)
374    }
375}
376
377/// Describes one retained Stream message returned by Stream Client reads.
378#[derive(Clone, Debug)]
379pub struct StreamMessage<T> {
380    /// Decoded application message.
381    pub value: T,
382    /// Opaque token to pass unchanged to the next read.
383    pub resume_token: String,
384    /// Server-assigned creation time.
385    pub created_time: SystemTime,
386    /// Client-provided source or Step-generated `#stepExecutionID` source.
387    pub source: String,
388}
389
390/// Contains one newest-first page returned by [`crate::Client::list_stream_messages`].
391///
392/// Pass a nonempty [`Self::next_page_token`] unchanged to the next call. Stream trimming may remove
393/// messages between pages.
394#[derive(Clone, Debug)]
395pub struct StreamMessagesPage<T> {
396    /// Retained Stream messages in newest-first order.
397    pub messages: Vec<StreamMessage<T>>,
398    /// Opaque token for the next older page, or empty on the final page.
399    pub next_page_token: String,
400}