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