Skip to main content

golem_rust/durable_streams/
mod.rs

1// Copyright 2026 Golem Cloud
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4
5//! External Durable Streams clients. The host owns HTTP, framing and authentication;
6//! these clients retain only replay-reconstructed checkpoints, buffers and producer state.
7//! Each client owns one `golem:agent/durable-streams@2.0.0` resource. Construction
8//! journals its immutable descriptor without HTTP; dropping the client releases it.
9//!
10//! Appends use the current Golem idempotence policy unchanged. With idempotence disabled,
11//! recovery of an interrupted POST can fail closed. Exactly-once effects require the peer
12//! to atomically retain producer deduplication state. Golem forks copy producer identity
13//! and pending data; divergent branches are not independent external producers.
14
15use crate::bindings::golem::agent::durable_streams as wire;
16use crate::schema::wit::wire::Secret;
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19use serde_json::value::RawValue;
20use std::sync::Arc;
21
22#[cfg(test)]
23use tests::resources as calls;
24#[cfg(not(test))]
25use wire as calls;
26
27pub use wire::{
28    DurableStreamAppendReceipt as AppendReceipt, DurableStreamCheckpoint as Checkpoint,
29    DurableStreamErrorKind as ErrorKind, DurableStreamTransport as Transport,
30};
31
32/// Producer identity and acknowledged sequence progress, reconstructed by replay.
33#[derive(Clone, Debug)]
34pub struct Producer {
35    pub id: String,
36    pub epoch: u64,
37    pub sequence: u64,
38}
39
40/// A host protocol failure or a local codec/state error. Errors never mean EOF.
41#[derive(Debug)]
42pub struct Error {
43    pub kind: ErrorKind,
44    pub message: String,
45    pub retry_after_ms: Option<u64>,
46    pub producer_epoch: Option<u64>,
47    pub expected_sequence: Option<u64>,
48}
49
50impl Error {
51    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
52        Self {
53            kind,
54            message: message.into(),
55            retry_after_ms: None,
56            producer_epoch: None,
57            expected_sequence: None,
58        }
59    }
60}
61
62impl From<wire::DurableStreamError> for Error {
63    fn from(value: wire::DurableStreamError) -> Self {
64        Self {
65            kind: value.kind,
66            message: value.message,
67            retry_after_ms: value.retry_after_ms,
68            producer_epoch: value.producer_epoch,
69            expected_sequence: value.expected_sequence,
70        }
71    }
72}
73
74impl std::fmt::Display for Error {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "{:?}: {}", self.kind, self.message)
77    }
78}
79impl std::error::Error for Error {}
80
81/// Consecutive failure budget, independent of each HTTP attempt's deadline.
82#[derive(Clone, Debug)]
83pub struct RetryOptions {
84    pub max_retries: u32,
85    pub initial_delay_ms: u64,
86    pub max_delay_ms: u64,
87}
88
89impl Default for RetryOptions {
90    fn default() -> Self {
91        Self {
92            max_retries: 8,
93            initial_delay_ms: 100,
94            max_delay_ms: 30_000,
95        }
96    }
97}
98
99impl RetryOptions {
100    fn delay(&self, error: &Error, failures: u32) -> Option<u64> {
101        if failures >= self.max_retries
102            || !matches!(
103                error.kind,
104                ErrorKind::Timeout
105                    | ErrorKind::Transport
106                    | ErrorKind::RateLimited
107                    | ErrorKind::Unavailable
108            )
109        {
110            return None;
111        }
112        let backoff = self
113            .initial_delay_ms
114            .saturating_mul(1u64 << failures.min(63))
115            .min(self.max_delay_ms);
116        Some(backoff.max(error.retry_after_ms.unwrap_or(0)))
117    }
118}
119
120async fn wait(delay_ms: u64) {
121    #[cfg(not(test))]
122    crate::wasip3::clocks::monotonic_clock::wait_for(delay_ms.saturating_mul(1_000_000)).await;
123    #[cfg(test)]
124    tests::WAITS.with_borrow_mut(|waits| waits.push(delay_ms));
125}
126
127#[derive(Clone, Debug)]
128pub struct ReadOptions {
129    pub checkpoint: Checkpoint,
130    /// Transport used after catch-up reaches the tail. The initial call is always catch-up.
131    pub transport: Transport,
132    pub timeout_ms: u64,
133    pub idle_delay_ms: u64,
134    pub retry: RetryOptions,
135    /// A secret capability, never its revealed value. Shared between independent readers/writers.
136    pub auth: Option<Arc<Secret>>,
137}
138
139impl Default for ReadOptions {
140    fn default() -> Self {
141        Self {
142            checkpoint: Checkpoint {
143                offset: "-1".into(),
144                cursor: None,
145            },
146            transport: Transport::LongPoll,
147            timeout_ms: 30_000,
148            idle_delay_ms: 100,
149            retry: RetryOptions::default(),
150            auth: None,
151        }
152    }
153}
154
155enum Items {
156    Json(Vec<Box<RawValue>>),
157    Bytes(Vec<u8>),
158}
159
160impl Items {
161    fn len(&self) -> usize {
162        match self {
163            Self::Json(items) => items.len(),
164            Self::Bytes(items) => items.len(),
165        }
166    }
167}
168
169struct Batch {
170    items: Items,
171    index: usize,
172    next: Checkpoint,
173    up_to_date: bool,
174    closed: bool,
175}
176
177/// A single-reader, one-batch-buffered external source. `&mut self` serializes reads.
178/// A delivered batch is drained before its opaque checkpoint is promoted.
179/// Dropping a pending `next` cancels that attempt without advancing the checkpoint.
180/// Live readers are reconstructed by replay, not serialized as native stream handles.
181pub struct ExternalDurableStream<T> {
182    reader: calls::DurableStreamReader,
183    mode: wire::DurableStreamMode,
184    request: wire::DurableStreamReadRequest,
185    options: ReadOptions,
186    decode: fn(&Items, usize) -> Result<T, Error>,
187    pending: Option<Batch>,
188    closed: bool,
189    failures: u32,
190    delay_ms: Option<u64>,
191}
192
193impl<T: DeserializeOwned> ExternalDurableStream<T> {
194    pub fn json(url: impl Into<String>, options: ReadOptions) -> Self {
195        Self::new(
196            url.into(),
197            options,
198            wire::DurableStreamMode::Json,
199            |items, index| {
200                let Items::Json(items) = items else {
201                    unreachable!()
202                };
203                serde_json::from_str(items[index].get()).map_err(|e| {
204                    Error::new(
205                        ErrorKind::ProtocolError,
206                        format!("JSON item decode failed: {e}"),
207                    )
208                })
209            },
210        )
211    }
212}
213
214impl ExternalDurableStream<u8> {
215    /// Bytes are a sequence, not the original append chunk boundaries.
216    pub fn bytes(url: impl Into<String>, options: ReadOptions) -> Self {
217        Self::new(
218            url.into(),
219            options,
220            wire::DurableStreamMode::Bytes,
221            |items, index| {
222                let Items::Bytes(items) = items else {
223                    unreachable!()
224                };
225                Ok(items[index])
226            },
227        )
228    }
229}
230
231impl<T> ExternalDurableStream<T> {
232    fn new(
233        url: String,
234        mut options: ReadOptions,
235        mode: wire::DurableStreamMode,
236        decode: fn(&Items, usize) -> Result<T, Error>,
237    ) -> Self {
238        let reader = calls::DurableStreamReader::new(
239            &wire::DurableStreamReaderOptions {
240                url,
241                mode,
242                timeout_ms: options.timeout_ms,
243            },
244            options.auth.as_deref(),
245        );
246        options.auth = None;
247        Self {
248            reader,
249            mode,
250            request: wire::DurableStreamReadRequest {
251                checkpoint: options.checkpoint.clone(),
252                transport: Transport::CatchUp,
253                content_type: None,
254            },
255            options,
256            decode,
257            pending: None,
258            closed: false,
259            failures: 0,
260            delay_ms: None,
261        }
262    }
263
264    /// Last fully drained checkpoint. A partially consumed batch retains its original offset.
265    pub fn checkpoint(&self) -> &Checkpoint {
266        &self.request.checkpoint
267    }
268
269    /// Stop pulling and discard buffered items. To cancel an active pull, drop its future first.
270    pub fn close(&mut self) {
271        self.closed = true;
272        self.pending = None;
273    }
274
275    fn install(&mut self, batch: wire::DurableStreamBatch) -> Result<(), Error> {
276        if batch.next.offset == "now" {
277            return Err(Error::new(
278                ErrorKind::ProtocolError,
279                "server did not resolve now",
280            ));
281        }
282        let items = match self.mode {
283            wire::DurableStreamMode::Json => Items::Json(if batch.payload.is_empty() {
284                Vec::new()
285            } else {
286                serde_json::from_slice(&batch.payload).map_err(|e| {
287                    Error::new(
288                        ErrorKind::ProtocolError,
289                        format!("JSON batch decode failed: {e}"),
290                    )
291                })?
292            }),
293            wire::DurableStreamMode::Bytes => Items::Bytes(batch.payload),
294        };
295        // The host validates MIME equivalence against the original pin.
296        self.request.content_type.get_or_insert(batch.content_type);
297        self.pending = Some(Batch {
298            items,
299            index: 0,
300            next: batch.next,
301            up_to_date: batch.up_to_date,
302            closed: batch.closed,
303        });
304        self.failures = 0;
305        Ok(())
306    }
307
308    pub async fn next(&mut self) -> Result<Option<T>, Error> {
309        loop {
310            if self.closed {
311                return Ok(None);
312            }
313            if let Some(batch) = &mut self.pending {
314                if batch.index < batch.items.len() {
315                    let item = (self.decode)(&batch.items, batch.index)?;
316                    batch.index += 1;
317                    return Ok(Some(item));
318                }
319                let batch = self.pending.take().unwrap();
320                self.request.checkpoint = batch.next;
321                self.closed = batch.closed;
322                self.request.transport = if batch.up_to_date {
323                    self.options.transport
324                } else {
325                    Transport::CatchUp
326                };
327                if batch.items.len() == 0 && batch.up_to_date && !batch.closed {
328                    self.delay_ms = Some(self.options.idle_delay_ms);
329                }
330                continue;
331            }
332            if let Some(delay) = self.delay_ms {
333                wait(delay).await;
334                self.delay_ms = None;
335            }
336            match self.reader.read(self.request.clone()).await {
337                Ok(batch) => self.install(batch)?,
338                Err(error) => {
339                    let error = Error::from(error);
340                    let Some(delay) = self.options.retry.delay(&error, self.failures) else {
341                        return Err(error);
342                    };
343                    self.failures += 1;
344                    self.delay_ms = Some(delay);
345                }
346            }
347        }
348    }
349
350    pub async fn collect(mut self) -> Result<Vec<T>, Error> {
351        let mut items = Vec::new();
352        while let Some(item) = self.next().await? {
353            items.push(item);
354        }
355        Ok(items)
356    }
357}
358
359#[cfg(feature = "export_golem_agentic")]
360impl<T: crate::IntoSchema + crate::FromSchema + Send + 'static> ExternalDurableStream<T> {
361    /// Preserve ordinary native stream schemas, including nested values and RPC inputs.
362    /// Local native reads return String errors; a forwarded producer failure traps, never EOF.
363    pub fn into_agent_stream(self) -> crate::agentic::AgentStream<T> {
364        crate::agentic::AgentStream::from_schema_stream(
365            crate::schema::SchemaValueStream::from_source(self),
366            |value| T::from_value(&value).map_err(|e| e.to_string()),
367        )
368    }
369}
370
371#[cfg(feature = "export_golem_agentic")]
372impl<T: crate::IntoSchema + Send + 'static> crate::schema::stream::SchemaValueStreamSource
373    for ExternalDurableStream<T>
374{
375    fn next(
376        &mut self,
377    ) -> std::pin::Pin<
378        Box<
379            dyn std::future::Future<
380                    Output = Result<Option<crate::schema::wit::wire::SchemaValueTree>, String>,
381                > + Send
382                + '_,
383        >,
384    > {
385        Box::pin(async move {
386            match ExternalDurableStream::next(self)
387                .await
388                .map_err(|e| e.to_string())?
389            {
390                Some(value) => crate::schema::wit::encode_value_async(&value.to_value())
391                    .await
392                    .map(Some)
393                    .map_err(|e| e.to_string()),
394                None => Ok(None),
395            }
396        })
397    }
398}
399
400#[derive(Clone, Debug)]
401pub struct WriteOptions {
402    /// None generates one durable identity at writer construction.
403    pub producer_id: Option<String>,
404    pub epoch: u64,
405    pub timeout_ms: u64,
406    pub retry: RetryOptions,
407    pub auth: Option<Arc<Secret>>,
408}
409
410impl Default for WriteOptions {
411    fn default() -> Self {
412        Self {
413            producer_id: None,
414            epoch: 0,
415            timeout_ms: 30_000,
416            retry: RetryOptions::default(),
417            auth: None,
418        }
419    }
420}
421
422struct PendingAppend {
423    request: wire::DurableStreamAppendRequest,
424    failures: u32,
425    delay_ms: Option<u64>,
426}
427
428/// A non-pipelining producer. A cancelled or failed append remains pending;
429/// call `retry_pending` to resolve that exact payload before supplying new data.
430/// Dropping this writer does not undo an external write.
431pub struct DurableStreamWriter {
432    writer: calls::DurableStreamWriter,
433    producer: Producer,
434    retry: RetryOptions,
435    pending: Option<PendingAppend>,
436    closed: bool,
437}
438
439const MAX_PRODUCER_NUMBER: u64 = (1u64 << 53) - 1;
440
441impl DurableStreamWriter {
442    pub fn new(
443        url: impl Into<String>,
444        content_type: impl Into<String>,
445        options: WriteOptions,
446    ) -> Result<Self, Error> {
447        if options.epoch > MAX_PRODUCER_NUMBER {
448            return Err(Error::new(
449                ErrorKind::InvalidRequest,
450                "producer epoch exceeds 2^53-1",
451            ));
452        }
453        let id = options
454            .producer_id
455            .clone()
456            .unwrap_or_else(|| crate::generate_idempotency_key().to_string());
457        Ok(Self {
458            writer: calls::DurableStreamWriter::new(
459                &wire::DurableStreamWriterOptions {
460                    url: url.into(),
461                    content_type: content_type.into(),
462                    producer_id: id.clone(),
463                    producer_epoch: options.epoch,
464                    timeout_ms: options.timeout_ms,
465                },
466                options.auth.as_deref(),
467            ),
468            producer: Producer {
469                id,
470                epoch: options.epoch,
471                sequence: 0,
472            },
473            retry: options.retry,
474            pending: None,
475            closed: false,
476        })
477    }
478
479    pub fn producer(&self) -> &Producer {
480        &self.producer
481    }
482    pub fn has_pending(&self) -> bool {
483        self.pending.is_some()
484    }
485
486    fn prepare(
487        &mut self,
488        payload: wire::DurableStreamAppendPayload,
489        close: bool,
490    ) -> Result<(), Error> {
491        if self.pending.is_some() {
492            return Err(Error::new(
493                ErrorKind::SequenceConflict,
494                "resolve the pending append before supplying new data",
495            ));
496        }
497        if self.closed {
498            return Err(Error::new(ErrorKind::Closed, "writer is closed"));
499        }
500        if self.producer.sequence > MAX_PRODUCER_NUMBER {
501            return Err(Error::new(
502                ErrorKind::InvalidRequest,
503                "producer sequence exhausted",
504            ));
505        }
506        let empty = match &payload {
507            wire::DurableStreamAppendPayload::Json(v) => v.is_empty(),
508            wire::DurableStreamAppendPayload::Bytes(v) => v.is_empty(),
509        };
510        if empty && !close {
511            return Err(Error::new(
512                ErrorKind::InvalidRequest,
513                "empty append requires close",
514            ));
515        }
516        self.pending = Some(PendingAppend {
517            request: wire::DurableStreamAppendRequest {
518                payload,
519                sequence: self.producer.sequence,
520                close,
521            },
522            failures: 0,
523            delay_ms: None,
524        });
525        Ok(())
526    }
527
528    /// Each element is encoded separately; an array-valued element stays one message.
529    pub async fn append_json<T: Serialize>(
530        &mut self,
531        values: &[T],
532        close: bool,
533    ) -> Result<AppendReceipt, Error> {
534        let encoded = values
535            .iter()
536            .map(serde_json::to_string)
537            .collect::<Result<Vec<_>, _>>()
538            .map_err(|e| {
539                Error::new(
540                    ErrorKind::InvalidRequest,
541                    format!("JSON encode failed: {e}"),
542                )
543            })?;
544        self.prepare(wire::DurableStreamAppendPayload::Json(encoded), close)?;
545        self.retry_pending().await
546    }
547
548    pub async fn append_bytes(
549        &mut self,
550        bytes: &[u8],
551        close: bool,
552    ) -> Result<AppendReceipt, Error> {
553        self.prepare(
554            wire::DurableStreamAppendPayload::Bytes(bytes.to_vec()),
555            close,
556        )?;
557        self.retry_pending().await
558    }
559
560    /// Close-only consumes a sequence just like an append-and-close.
561    pub async fn close(&mut self) -> Result<AppendReceipt, Error> {
562        self.prepare(wire::DurableStreamAppendPayload::Bytes(Vec::new()), true)?;
563        self.retry_pending().await
564    }
565
566    fn acknowledge(&mut self, receipt: &AppendReceipt) -> Result<(), Error> {
567        let pending = self
568            .pending
569            .as_ref()
570            .expect("acknowledgement requires a pending request");
571        if receipt.epoch != self.producer.epoch || receipt.sequence < pending.request.sequence {
572            return Err(Error::new(
573                ErrorKind::ProtocolError,
574                "invalid producer acknowledgement",
575            ));
576        }
577        if receipt.sequence > pending.request.sequence {
578            return Err(Error::new(
579                ErrorKind::ProducerDiverged,
580                "peer acknowledged a later producer sequence",
581            ));
582        }
583        if pending.request.close && !receipt.closed {
584            return Err(Error::new(
585                ErrorKind::ProtocolError,
586                "peer did not acknowledge closure",
587            ));
588        }
589        self.producer.sequence = pending.request.sequence + 1;
590        self.closed = receipt.closed;
591        self.pending = None;
592        Ok(())
593    }
594
595    pub async fn retry_pending(&mut self) -> Result<AppendReceipt, Error> {
596        loop {
597            let pending = self
598                .pending
599                .as_mut()
600                .ok_or_else(|| Error::new(ErrorKind::InvalidRequest, "no pending append"))?;
601            if let Some(delay) = pending.delay_ms {
602                wait(delay).await;
603                pending.delay_ms = None;
604            }
605            match self.writer.append(pending.request.clone()).await {
606                Ok(receipt) => {
607                    self.acknowledge(&receipt)?;
608                    return Ok(receipt);
609                }
610                Err(error) => {
611                    let error = Error::from(error);
612                    let Some(delay) = self.retry.delay(&error, pending.failures) else {
613                        return Err(error);
614                    };
615                    pending.failures += 1;
616                    pending.delay_ms = Some(delay);
617                }
618            }
619        }
620    }
621}
622
623#[cfg(test)]
624mod tests;