GenStage Documentation

repository·main·Indexed 23 days ago

https://github.com/elixir-lang/gen_stage

A framework for building concurrent, multi-stage data processing pipelines in Elixir using a producer-consumer model with built-in back-pressure. It provides the GenStage behaviour for implementing producer, consumer, and producer-consumer stages, as well as ConsumerSupervisor for spawning child processes for every received event.

Tokens
7.6K
Snippets
14
Records
32
Agent score
82%

What's inside GenStage

  1. What is GenStage?

    main

    GenStage is a specification for exchanging events between producers and consumers. It provides a mechanism for building data processing pipelines with built-in back-pressure. The project provides two primary components:

    • GenStage: A behaviour used to implement producer and consumer stages.
    • ConsumerSupervisor: A supervisor designed to consume events from a GenStage producer and start a separate child process for every event received.
  2. Explore GenStage usage examples

    main

    The repository contains several practical examples of how to use GenStage and ConsumerSupervisor:

    • ProducerConsumer: Demonstrates a simple pipeline of stages (e.g., A -> B -> C) where events flow through the stages.
    • ConsumerSupervisor: Shows how to use one or more ConsumerSupervisor processes to consume events from a producer (e.g., a counter).
    • GenEvent: Demonstrates using GenStage as a concurrent alternative to GenEvent, offering more flexibility with buffer sizes and back-pressure.
    • RateLimiter: Shows how to implement rate limiting within a GenStage pipeline.
  3. What is a ConsumerSupervisor and how does it work?

    main

    A ConsumerSupervisor is a specialized supervisor designed to be used as a consumer in a GenStage pipeline. Instead of managing a static set of children, it starts a new child process for every event that flows in from a producer.

    Key Mechanics

    • Event-to-Process Mapping: Each event received from a producer is appended to the arguments of the child specification, triggering the start of a new process.
    • Demand Management: The supervisor manages demand automatically. It asks the producer for :max_demand events. As child processes terminate, the supervisor accumulates demand and requests more events once :min_demand is reached. This effectively creates a dynamic pool where the concurrency is bounded by these demand settings.
    • Subscription: It can be attached to a producer by returning :subscribe_to from the init/1 callback or by using GenStage.sync_subscribe/3 or GenStage.async_subscribe/2.

    Important Constraints

    • Restart Policy: You cannot use :restart, :permanent for children. Because children are spawned per event, :permanent is not supported. You must explicitly set the :restart option to either :temporary (never restarted) or :transient (restarted only on abnormal exits).
    • Process Linking: The child's start_link function must return {:ok, pid} and the process must be linked back to the supervisor (e.g., using Task.start_link/1).
    defmodule Consumer do
      use ConsumerSupervisor
    
      def start_link(arg) do
        ConsumerSupervisor.start_link(__MODULE__, arg)
      end
    
      def init(_arg) do
        # Note: You must explicitly set :restart to :temporary or :transient
        children = [%{id: Printer, start: {Printer, :start_link, []}, restart: :transient}]
        opts = [strategy: :one_for_one, subscribe_to: [{Producer, max_demand: 50}]]
        ConsumerSupervisor.init(children, opts)
      end
    end
    
    defmodule Printer do
      def start_link(event) do
        # Must return {:ok, pid} and be linked to the supervisor
        Task.start_link(fn ->
          IO.inspect({self(), event})
        end)
      end
    end
  4. Configure subscription demand (max_demand and min_demand)

    main

    To control the flow of data and optimize buffering, you can set :max_demand and :min_demand when subscribing to a producer.

    • :max_demand: The maximum number of events the consumer will ask for at one time.
    • :min_demand: The threshold of remaining demand that triggers a new request. When the number of unconsumed events drops to this level, the consumer sends more demand upstream to reach :max_demand again.

    Example logic: If :max_demand is 1000 and :min_demand is 750, and the producer sends batches of 100:

    1. Consumer asks for 1000.
    2. Producer sends 100 (demand is now 900).
    3. Producer sends 100 (demand is now 800).
    4. Producer sends 100 (demand is now 700). Since 700 < 750, the consumer triggers a new request for 250 to return to 1000.
  5. Understand GenStage stage types

    main

    GenStage supports three primary stage types, which dictate how they interact with others:

    1. Producer: Emits events to consumers. It can manage a buffer and handle demand from consumers.
    2. Consumer: Subscribes to producers and receives events. It can request demand (automatically or manually).
    3. ProducerConsumer: Acts as both a consumer and a producer. It consumes events from upstream producers and produces events for downstream consumers, often using a buffer to bridge the two.
  6. What are GenStage stages and how do they work?

    main

    GenStage is a framework for building data-exchange pipelines using stages. A stage can play one of three roles:

    1. Producer (or Source): Only produces and sends data.
    2. Consumer (or Sink): Only receives and consumes data.
    3. Producer-Consumer: Acts as both, receiving data from an upstream producer and sending transformed data to a downstream consumer.

    Communication is driven by demand. Consumers send demand upstream to producers. A producer will never emit more events than the consumer has requested, providing a built-in back-pressure mechanism. This allows you to build pipelines that scale based on runtime needs like concurrency and data transfer rather than just code organization.

    defmodule A do
      use GenStage
    
      def init(counter) do
        {:producer, counter}
      end
    
      def handle_demand(demand, counter) when demand > 0 do
        events = Enum.to_list(counter..counter+demand-1)
        {:noreply, events, counter + demand}
      end
    end
  7. How to handle asynchronous work in GenStage

    main

    By default, :consumer and :producer_consumer stages send demand upstream immediately after handle_events/3 finishes. This assumes all work in handle_events/3 is synchronous.

    If you need to perform asynchronous work, you must implement the handle_subscribe/4 callback and return {:manual, state} instead of the default {:automatic, state}. When in :manual mode, you are responsible for sending demand upstream using GenStage.ask/3. You must manually respect the :max_demand and :min_demand values set during subscription.

    def handle_subscribe(:producer, opts, from, producers) do
      pending = opts[:max_demand] || 1000
      interval = opts[:interval] || 5000
      producers = Map.put(producers, from, {pending, interval})
      producers = ask_and_schedule(producers, from)
      {:manual, producers}
    end
  8. Manage subscription cancellation modes

    main

    The cancel option in subscription options determines the behavior when a process involved in a subscription terminates:

    • :permanent: The subscription is treated as a permanent link. If the consumer or producer fails, the stage may stop.
    • :transient: The subscription is cancelled if the reason for termination is not a transient shutdown.
    • :temporary: The subscription is treated as a temporary connection and is more resilient to certain types of process exits.
  9. How `GenStage.BroadcastDispatcher` works

    main

    GenStage.BroadcastDispatcher is a dispatcher designed to broadcast events to all subscribers. It guarantees that events are dispatched to all consumers without exceeding the demand of any individual consumer.

    It achieves this by accumulating demand from all consumers before broadcasting. If a consumer uses a :selector, the dispatcher filters the events for that specific consumer. If a selector discards events, the dispatcher manages the internal demand accounting to ensure the consumer eventually receives the requested amount of valid events.

  10. Understand the 'Even Distribution' assumption in PartitionDispatcher

    main

    GenStage.PartitionDispatcher assumes that data is evenly distributed across all partitions.

    If your data is consistently uneven (e.g., 60% of events go to partition A and 20% to B and C), the producer may struggle to manage demand efficiently. Because the producer cannot distinguish which specific partition/consumer is driving demand, it may buffer excessive data for busy partitions while others remain underutilized. This behavior is acceptable for short-lived spikes but can lead to memory or performance issues if the imbalance is permanent.

  11. Implement a ConsumerSupervisor using `use ConsumerSupervisor`

    main

    To create a custom consumer supervisor, use the ConsumerSupervisor module. This provides the necessary boilerplate and implements the GenStage behaviour.

    Implementation Steps

    1. use ConsumerSupervisor in your module.
    2. Implement start_link/1 to call ConsumerSupervisor.start_link/3.
    3. Implement init/1. The init/1 callback must return {:ok, children, opts} or :ignore.

    Child Specification Requirements

    When defining children in init/1, ensure the :restart option is set to :temporary or :transient. Using :permanent will result in an error.

    Example

    defmodule MyConsumer do
      use ConsumerSupervisor
    
      def start_link(arg) do
        ConsumerSupervisor.start_link(__MODULE__, arg)
      end
    
      def init(_arg) do
        # Define the template for children to be spawned per event
        children = [%{id: Worker, start: {Worker, :start_link, []}, restart: :transient}]
        # Configure subscription and strategy
        opts = [strategy: :one_for_one, subscribe_to: [{Producer, max_demand: 20, min_demand: 10}]]
        ConsumerSupervisor.init(children, opts)
      end
    end
    defmodule Consumer do
            use ConsumerSupervisor
    
    def start_link(arg) do
              ConsumerSupervisor.start_link(__MODULE__, arg)
            end
    
    def init(_arg) do
              # Note: By default the restart for a child is set to :permanent
              # which is not supported in ConsumerSupervisor. You need to explicitly
              # set the :restart option either to :temporary or :transient.
              children = [%{id: Printer, start: {Printer, :start_link, []}, restart: :transient}]
              opts = [strategy: :one_for_one, subscribe_to: [{Producer, max_demand: 50}]]
              ConsumerSupervisor.init(children, opts)
            end
          end
    
    Then in the `Printer` module:
    
    defmodule Printer do
            def start_link(event) do
              # Note: this function must return the format of `{:ok, pid}` and like
              # all children started by a Supervisor, the process must be linked
              # back to the supervisor (if you use `Task.start_link/1` then both
              # requirements are met automatically)
              Task.start_link(fn ->
                IO.inspect({self(), event})
              end)
            end
    end