LemonAi.EventStream (lemon_ai v0.1.0)

View Source

Async event stream for streaming LLM responses with OTP-compliant lifecycle management.

This module provides a producer/consumer pattern for streaming events from LLM providers with the following BEAM/OTP guarantees:

Features

  • Owner Monitoring: Streams are linked to an owner process and automatically cancel when the owner dies.
  • Task Linking: Streaming tasks can be attached and are properly shutdown when the stream is canceled.
  • Bounded Queues: Configurable queue limits prevent unbounded memory growth.
  • Backpressure: push/2 returns :ok | {:error, :overflow} for flow control.

  • Cancellation: Explicit cancel/2 API for clean stream termination.
  • Timeouts: Configurable stream timeout with automatic cancellation.

Usage

# Start a stream with options
{:ok, stream} = EventStream.start_link(
  owner: self(),
  max_queue: 1000,
  timeout: 300_000
)

# Attach a supervised task
{:ok, task_pid} = Task.Supervisor.start_child(LemonAi.StreamTaskSupervisor, fn ->
  # ... streaming logic ...
end)
EventStream.attach_task(stream, task_pid)

# Producer pushes events (with backpressure)
case EventStream.push(stream, {:text_delta, 0, "Hello", partial}) do
  :ok -> :continue
  {:error, :overflow} -> :stop_producing
end

# Consumer reads events
stream
|> EventStream.events()
|> Enum.each(fn event -> IO.inspect(event) end)

# Or cancel explicitly
EventStream.cancel(stream, :user_requested)

Summary

Functions

Attach a streaming task to this event stream.

Cancel the stream with a reason.

Returns a specification to start this module under a supervisor.

Collect all text from the stream into a single string.

Complete the stream with a final message.

Signal an error on the stream.

Get a lazy enumerable of events from the stream.

Push an event to the stream (synchronous with backpressure).

Push an event to the stream (asynchronous, fire-and-forget).

Get the final result of the stream, blocking until complete.

Start a new event stream.

Get current queue statistics.

Types

drop_strategy()

@type drop_strategy() :: :drop_oldest | :drop_newest | :error

event()

option()

@type option() ::
  {:owner, pid()}
  | {:max_queue, pos_integer()}
  | {:drop_strategy, drop_strategy()}
  | {:timeout, timeout()}

t()

@type t() :: pid()

Functions

attach_task(stream, task_pid)

@spec attach_task(t(), pid()) :: :ok

Attach a streaming task to this event stream.

When the stream is canceled or the owner dies, the attached task will be shutdown using Task.shutdown/2.

cancel(stream, reason \\ :canceled)

@spec cancel(t(), term()) :: :ok

Cancel the stream with a reason.

This will:

  1. Mark the stream as canceled
  2. Shutdown any attached streaming task
  3. Wake up all waiters with a terminal event
  4. Stop the GenServer

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

collect_text(stream)

@spec collect_text(t()) :: String.t()

Collect all text from the stream into a single string.

complete(stream, message)

@spec complete(t(), LemonAi.Types.AssistantMessage.t()) :: :ok

Complete the stream with a final message.

error(stream, message)

@spec error(t(), LemonAi.Types.AssistantMessage.t()) :: :ok

Signal an error on the stream.

events(stream)

@spec events(t()) :: Enumerable.t()

Get a lazy enumerable of events from the stream.

This returns a Stream that will block when no events are available and complete when the stream is done. Terminal events (done/error/canceled) are included in the stream before it halts.

push(stream, event)

@spec push(t(), event()) :: :ok | {:error, :overflow | :canceled}

Push an event to the stream (synchronous with backpressure).

Returns :ok on success or {:error, :overflow} if the queue is full (when using :error drop strategy) or {:error, :canceled} if the stream has been canceled.

Use push_async/2 if you don't need backpressure feedback.

push_async(stream, event)

@spec push_async(t(), event()) :: :ok

Push an event to the stream (asynchronous, fire-and-forget).

This is a non-blocking push that ignores backpressure. Use push/2 if you need confirmation that the event was accepted.

If using :drop_oldest or :drop_newest strategies, events will be dropped silently on overflow. With :error strategy, overflow events are still dropped but a warning is logged.

result(stream, timeout \\ :infinity)

@spec result(t(), timeout()) ::
  {:ok, LemonAi.Types.AssistantMessage.t()}
  | {:error, LemonAi.Types.AssistantMessage.t() | term()}

Get the final result of the stream, blocking until complete.

start_link(opts \\ [])

@spec start_link([option()]) :: GenServer.on_start()

Start a new event stream.

Options

  • :owner - Process to monitor. Stream cancels if owner dies. Default: self()
  • :max_queue - Maximum events to buffer. Default: 10000
  • :drop_strategy - What to do on overflow: :drop_oldest, :drop_newest, or :error. Default: :error
  • :timeout - Stream timeout in milliseconds. Default: 300000ms

stats(stream)

@spec stats(t()) :: %{
  queue_size: non_neg_integer(),
  max_queue: pos_integer(),
  dropped: non_neg_integer()
}

Get current queue statistics.