Stream Processing

architecture

Stream Processing

Definition and Overview

Stream processing is a data processing paradigm in which records are processed continuously and incrementally as they arrive, rather than being accumulated into finite batches for periodic processing. A stream processing system ingests an unbounded sequence of events, applies transformations, aggregations, or analyses to each event (or small groups of events), and produces results with minimal latency. The fundamental distinction from batch processing is temporal: batch systems process data at rest, while stream systems process data in motion.

Stream processing systems must address several challenges that do not arise in batch processing. Events may arrive out of order due to network delays or distributed clock skew, requiring windowing strategies and watermark mechanisms to determine when a time-based aggregation is complete. Processing must handle failures gracefully without losing or duplicating events, demanding exactly-once or at-least-once delivery guarantees. And throughput must be managed dynamically through backpressure mechanisms that slow producers when consumers cannot keep pace, preventing memory exhaustion and cascading failures.

The BEAM virtual machine and the Elixir ecosystem provide a uniquely strong foundation for stream processing. The lightweight process model (millions of concurrent processes with microsecond scheduling), built-in distribution, and fault-tolerant supervision trees address many stream processing challenges at the runtime level. Libraries like GenStage and Broadway build on this foundation to provide demand-driven, backpressure-aware stream processing with production-grade reliability.

Historical Context and Evolution

Stream processing has evolved through several generations, each addressing limitations of its predecessors. The first generation, represented by systems like Apache Storm (2011), provided low-latency processing but with limited exactly-once guarantees and complex fault recovery. The second generation, exemplified by Apache Spark Streaming (2013), used micro-batching to achieve better fault tolerance at the cost of higher latency. The third generation, led by Apache Flink (2014) and Apache Kafka Streams (2016), achieved true event-at-a-time processing with strong consistency guarantees and sophisticated windowing.

The Elixir ecosystem takes a different approach entirely. Rather than building monolithic stream processing frameworks, it provides composable building blocks that leverage the BEAM's native concurrency model. GenStage (2016) provides the foundational demand-driven data exchange protocol. Broadway (2019) adds production concerns like batching, graceful shutdown, and telemetry on top of GenStage. Flow (2016) provides parallel data processing for bounded datasets. These tools compose naturally with OTP supervision, GenServer state management, and Phoenix PubSub for real-time UI updates. The result is an integrated streaming architecture that lives within the application rather than requiring a separate cluster.

Batch vs Stream Processing

Understanding the tradeoffs between batch and stream processing is essential for choosing the right approach for a given workload.

DimensionBatch ProcessingStream Processing
InputBounded dataset (finite)Unbounded event stream (infinite)
LatencyMinutes to hoursMilliseconds to seconds
CompletenessAll data available before processingData arrives continuously, may be late
StateRecomputed per batchMaintained incrementally
Fault recoveryReprocess entire batchCheckpoint and resume from offset
ThroughputOptimized for total volumeOptimized for per-event speed
OrderingGuaranteed (data is sorted)Must be managed (watermarks, windows)
Resource usageBursty (peaks during batch runs)Steady (continuous processing)

Modern architectures, including the Prismatic Platform, often combine both: stream processing handles real-time intelligence feeds and security monitoring, while batch processing handles periodic bulk analyses like comprehensive asset inventory reconciliation and historical trend computation.

Windowing Strategies

Windowing partitions an unbounded stream into finite chunks for aggregation. The choice of windowing strategy determines how events are grouped and when results are emitted.

Tumbling Windows

Tumbling (or fixed) windows divide the stream into non-overlapping, fixed-size time intervals. Each event belongs to exactly one window.

Time:     |--W1--|--W2--|--W3--|--W4--|
Events:   * * *  | * *  | * * *| *   |
Output:       3      2      3     1

Use case: Computing per-minute event counts for security monitoring dashboards.

Sliding Windows

Sliding windows overlap, defined by a window size and a slide interval. An event may belong to multiple windows simultaneously.

Window size: 10min, Slide: 5min
Time:     |----W1----|
               |----W2----|
                    |----W3----|

Use case: Calculating rolling averages for security rating trends with smooth transitions.

Session Windows

Session windows are data-driven, grouping events that are close together in time with gaps defining session boundaries. Window size is variable, determined by the data itself.

Events:   ** * *      *** **         *  * *
Sessions: |--S1--|    |--S2--|      |--S3--|
          (gap)       (gap)         (gap)

Use case: Grouping related OSINT findings from a single reconnaissance session against a target domain.

Window Summary

Window TypeSizeOverlapTriggerBest For
TumblingFixedNoneTime boundaryRegular aggregations
SlidingFixedYes (by slide)Slide intervalSmooth trending
SessionVariableNoneGap timeoutActivity-based grouping
GlobalUnboundedN/AWatermark/triggerFull-stream aggregation

Watermarks and Late Data

In distributed systems, events may arrive after the window to which they logically belong has already been processed. Watermarks provide a mechanism for reasoning about event-time completeness.

A watermark is a monotonically advancing timestamp that asserts: "all events with timestamps before this value have been observed." When a watermark advances past a window's end time, the system can safely emit results for that window.

Event time:    |---Window---|
Processing:    * * * [watermark advances past window end] * (late event)
                     ^-- emit window results here
                                                          ^-- handle as late data

Late data handling strategies include:

StrategyDescriptionTradeoff
DropDiscard late eventsSimple, may lose data
RecomputeRe-emit corrected window resultsAccurate, downstream complexity
Side outputRoute late data to separate streamFlexible, requires dual processing
Allowed latenessAccept late events within a grace periodBalanced, bounded delay

In the Prismatic Platform, the allowed lateness strategy is preferred for OSINT data streams, where API responses from providers like Shodan and Censys may arrive with variable latency. A configurable grace period (typically 30-60 seconds) accommodates normal network variability without sacrificing timeliness.

Delivery Guarantees

Stream processing systems provide different levels of delivery guarantees, each with distinct implementation complexity and performance characteristics.

GuaranteeDescriptionImplementation CostData Accuracy
At-most-onceEvents may be lost, never duplicatedLowestMay miss events
At-least-onceEvents never lost, may be duplicatedModerateMay count events twice
Exactly-onceEvents processed exactly onceHighestPerfect accuracy

True exactly-once semantics require coordination between the stream processor and external systems (databases, message queues). Broadway achieves effectively-exactly-once processing through a combination of:

  1. Acknowledgment tracking: Each message is acknowledged only after successful processing and persistence
  2. Idempotent writes: Storage operations use unique event identifiers to detect and skip duplicates
  3. Checkpoint-based recovery: On failure, processing resumes from the last acknowledged checkpoint
defmodule PrismaticOsint.IdempotentWriter do
  @moduledoc """
  Demonstrates idempotent write pattern for exactly-once semantics.
  Uses event_id as deduplication key to prevent duplicate processing.
  """

  @spec write_finding(map()) :: {:ok, map()} | {:error, :duplicate} | {:error, term()}
  def write_finding(%{event_id: event_id} = finding) do
    case PrismaticStorage.Repo.get_by(Finding, event_id: event_id) do
      nil ->
        %Finding{}
        |> Finding.changeset(finding)
        |> PrismaticStorage.Repo.insert()

      _existing ->
        {:error, :duplicate}
    end
  end
end

GenStage and Broadway in Elixir

The Elixir ecosystem's approach to stream processing is built on two complementary libraries that leverage BEAM's process model.

GenStage

GenStage is the foundational library implementing demand-driven data exchange between Elixir processes. It defines three process roles:

defmodule PrismaticOsint.Producer do
  @moduledoc """
  GenStage producer that generates OSINT events from external sources.
  Produces events only when downstream consumers request them.
  """

  use GenStage

  @impl GenStage
  def init(opts) do
    {:producer, %{source: opts[:source], buffer: []}}
  end

  @impl GenStage
  def handle_demand(demand, state) when demand > 0 do
    events = fetch_osint_events(state.source, demand)
    {:noreply, events, update_state(state, events)}
  end
end

defmodule PrismaticOsint.Enrichment do
  @moduledoc """
  GenStage producer-consumer that enriches OSINT events with
  contextual information from multiple intelligence sources.
  """

  use GenStage

  @impl GenStage
  def init(_opts), do: {:producer_consumer, %{}}

  @impl GenStage
  def handle_events(events, _from, state) do
    enriched = Enum.map(events, &enrich_with_context/1)
    {:noreply, enriched, state}
  end
end

defmodule PrismaticOsint.StorageConsumer do
  @moduledoc """
  GenStage consumer that persists processed OSINT findings
  to the authoritative PostgreSQL store.
  """

  use GenStage

  @impl GenStage
  def init(_opts), do: {:consumer, %{}}

  @impl GenStage
  def handle_events(events, _from, state) do
    Enum.each(events, &persist_finding/1)
    {:noreply, [], state}
  end
end

The demand-driven model means consumers request events from upstream producers, creating natural backpressure: if a consumer is slow, it requests fewer events, which propagates upstream to slow the producer. No events are buffered beyond what consumers have explicitly requested.

Broadway

Broadway builds on GenStage to provide a higher-level abstraction for production stream processing pipelines:

defmodule PrismaticOsint.SecurityFeedPipeline do
  @moduledoc """
  Production Broadway pipeline for processing security event feeds.
  Supports multiple batchers for routing events to different storage backends.
  """

  use Broadway

  @spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}
  def start_link(_opts) do
    Broadway.start_link(__MODULE__,
      name: __MODULE__,
      producer: [
        module: {BroadwayKafka.Producer, hosts: [...], topics: ["security-events"]},
        concurrency: 4
      ],
      processors: [
        default: [concurrency: 16, max_demand: 10]
      ],
      batchers: [
        postgresql: [concurrency: 4, batch_size: 100, batch_timeout: 1000],
        meilisearch: [concurrency: 2, batch_size: 50, batch_timeout: 2000]
      ]
    )
  end

  @impl Broadway
  def handle_message(:default, message, _context) do
    enriched = enrich_security_event(message.data)
    message
    |> Broadway.Message.update_data(fn _ -> enriched end)
    |> Broadway.Message.put_batcher(select_batcher(enriched))
  end

  @impl Broadway
  def handle_batch(:postgresql, messages, _batch_info, _context) do
    records = Enum.map(messages, & &1.data)
    PrismaticStorage.Ecto.bulk_insert(records)
    messages
  end

  @impl Broadway
  def handle_batch(:meilisearch, messages, _batch_info, _context) do
    documents = Enum.map(messages, & &1.data)
    PrismaticStorage.Meilisearch.index_documents(documents)
    messages
  end

  defp select_batcher(%{severity: severity}) when severity in [:critical, :high], do: :postgresql
  defp select_batcher(_event), do: :meilisearch
end

Broadway adds automatic batching, graceful shutdown, telemetry integration, and rate limiting on top of GenStage's demand-driven foundation.

OSINT Data Streams in Prismatic

The Prismatic Platform processes several categories of real-time data streams using the Broadway/GenStage infrastructure.

StreamSourceVolumeProcessingDestination
Security eventsEASM scanners~10K/hourEnrichment, scoringPostgreSQL, LiveView
OSINT feedsShodan, Censys, GreyNoiseVariableNormalization, dedupMeilisearch, KuzuDB
Certificate transparencyCT log monitors~100K/dayDomain matchingAsset inventory
DNS changesPassive DNS feeds~50K/dayAnomaly detectionAlert pipeline
Quality telemetryPlatform instrumentationContinuousAggregation, trendingETS, dashboards

Each stream is implemented as a supervised Broadway pipeline with independent concurrency settings, failure handling, and backpressure configuration. The OTP supervision tree ensures that a failure in one stream pipeline does not affect others thanks to process isolation.

LiveView Real-Time Integration

Stream processing results feed directly into Phoenix LiveView dashboards through PubSub broadcasts. When a Broadway pipeline processes a security event, it publishes the result to a PubSub topic. LiveView processes subscribed to that topic receive the update and push it to the browser over WebSocket, achieving end-to-end latency from event ingestion to UI update measured in milliseconds.

defmodule PrismaticWeb.PerimeterDashboardLive do
  @moduledoc """
  LiveView dashboard receiving real-time security event updates
  from Broadway pipelines via PubSub. Zero-polling architecture.
  """

  use PrismaticWeb, :live_view

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(PrismaticWeb.PubSub, "security:ratings:all")
      Phoenix.PubSub.subscribe(PrismaticWeb.PubSub, "security:events:latest")
    end

    {:ok, assign(socket, ratings: [], events: [])}
  end

  @impl Phoenix.LiveView
  def handle_info({:rating_updated, rating}, socket) do
    {:noreply, update(socket, :ratings, fn ratings ->
      [rating | ratings] |> Enum.take(50)
    end)}
  end

  @impl Phoenix.LiveView
  def handle_info({:security_event, event}, socket) do
    {:noreply, update(socket, :events, fn events ->
      [event | events] |> Enum.take(100)
    end)}
  end
end

This architecture eliminates polling entirely: the Prismatic Perimeter dashboard at /perimeter displays real-time security ratings, asset discovery progress, and compliance status with zero client-side polling overhead.

Error Handling and Dead Letter Queues

Production stream processing pipelines must handle messages that consistently fail processing. Rather than retrying indefinitely or dropping failed messages silently, the dead letter queue (DLQ) pattern routes failed messages to a separate store for investigation:

defmodule PrismaticOsint.DeadLetterHandler do
  @moduledoc """
  Handles messages that fail processing after exhausting retries.
  Routes to dead letter storage for manual investigation and replay.
  """

  @spec handle_failed_message(Broadway.Message.t(), term()) :: :ok
  def handle_failed_message(message, reason) do
    dead_letter = %{
      original_data: message.data,
      failure_reason: inspect(reason),
      failed_at: DateTime.utc_now(),
      pipeline: message.metadata[:pipeline],
      retry_count: message.metadata[:retry_count] || 0
    }

    PrismaticStorage.Repo.insert!(%DeadLetter{} |> DeadLetter.changeset(dead_letter))

    :telemetry.execute(
      [:prismatic, :pipeline, :dead_letter],
      %{count: 1},
      %{pipeline: dead_letter.pipeline, reason: dead_letter.failure_reason}
    )
  end
end

Monitoring and Observability

Stream processing pipelines require comprehensive observability to detect processing delays, backpressure buildup, and throughput degradation.

MetricDescriptionAlert Threshold
Processing latencyTime from event ingestion to output> 5 seconds
Backpressure ratioDemand vs supply rate< 0.5 (consumer starving)
Failed message ratePercentage of messages failing processing> 1%
Batch completion timeTime to process a single batch> 10 seconds
Queue depthMessages waiting in producer buffer> 10,000
Consumer utilizationPercentage of time consumers are busy> 90% sustained
Dead letter rateMessages routed to DLQ per minute> 10/minute

Broadway integrates with Telemetry, emitting events at each pipeline stage that the platform's observability infrastructure collects, aggregates, and visualizes.

defmodule PrismaticTelemetry.PipelineMetrics do
  @moduledoc """
  Attaches telemetry handlers for Broadway pipeline monitoring.
  Tracks throughput, latency, and error rates across all pipelines.
  """

  @spec attach() :: :ok
  def attach do
    :telemetry.attach_many("pipeline-metrics", [
      [:broadway, :processor, :start],
      [:broadway, :processor, :stop],
      [:broadway, :processor, :exception],
      [:broadway, :batcher, :start],
      [:broadway, :batcher, :stop]
    ], &handle_event/4, %{})
  end

  defp handle_event([:broadway, :processor, :stop], measurements, metadata, _config) do
    :telemetry.execute(
      [:prismatic, :pipeline, :processor_duration],
      %{duration_ms: System.convert_time_unit(measurements.duration, :native, :millisecond)},
      %{pipeline: metadata.name}
    )
  end
end

Performance Tuning

Tuning Broadway pipelines involves balancing concurrency, batch size, and demand settings for optimal throughput and latency:

ParameterEffect of IncreasingEffect of Decreasing
processor.concurrencyHigher throughput, more memoryLower throughput, less resource usage
batcher.batch_sizeBetter bulk write efficiencyLower latency per event
batcher.batch_timeoutLarger batches (waits longer)Faster emission of partial batches
processor.max_demandMore events per processor cycleFiner-grained backpressure
producer.concurrencyMore parallel source readsFewer connections to source

Comparison with External Frameworks

FrameworkLanguageBackpressureFault ToleranceDeployment
BroadwayElixirBuilt-in (GenStage demand)OTP supervisionEmbedded in application
Apache Kafka StreamsJavaTopic partitionsChangelog topicsEmbedded in application
Apache FlinkJava/ScalaNetwork buffersCheckpointingStandalone cluster
Apache Spark StreamingScala/JavaMicro-batch sizingRDD lineage replayStandalone cluster
Akka StreamsScalaReactive StreamsActor supervisionEmbedded in application

Broadway's key advantage is integration with the OTP ecosystem: pipelines are supervised processes that restart on failure, communicate via message passing, and compose naturally with GenServers, ETS caches, and Phoenix LiveView. This eliminates the operational complexity of maintaining a separate stream processing cluster.

  • Data Pipeline -- General pipeline pattern including both batch and stream processing
  • Broadway -- Elixir library for concurrent stream processing with batching and backpressure
  • GenStage -- Demand-driven data exchange powering stream pipelines
  • Backpressure -- Flow control preventing stream processing overload
  • ETL -- Extract-Transform-Load pattern, often implemented as streaming pipeline
  • Event Sourcing -- Event-based state management complementing stream processing
  • LiveView -- Real-time UI consuming processed stream data
  • PubSub -- Internal message distribution bridging pipelines and consumers
  • Telemetry -- Observability infrastructure for pipeline health monitoring
  • Observability -- Monitoring infrastructure for stream pipeline health
  • GenServer -- Process model underlying GenStage producers and consumers
  • Process Isolation -- BEAM property enabling independent pipeline stage failures

See Also

  • Architecture -- Streaming architecture patterns and pipeline topology
  • Technologies -- Stream processing stack and library ecosystem
  • Apps -- Prismatic applications implementing stream processing pipelines

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL