Official Broadway producer for PgQue — a high-throughput, zero-bloat background queue engine for PostgreSQL.


Features

  • Zero Table Bloat: Integrates with PgQue's rotating partitioned tables and instant TRUNCATE cycle.
  • Demand-Driven Concurrency: Native backpressure and memory safety backed by Broadway and GenStage.
  • Adaptive Idle Backoff: Automatically backs off polling intervals when queues are empty to preserve database CPU.
  • Instant Wake-Up: Bypasses idle polling backoffs immediately when new messages are enqueued via BroadwayPgQue.wake_up/1.
  • Custom Transformers: Easily decode JSON or translate raw PgQue records into domain structs.
  • Flexible Acknowledgements: Built-in support for PgQue batch acknowledgements, delayed retries, and dead-letter queue (DLQ) routing.
  • Cooperative Subconsumers: Built-in support for PgQue consumer groups (pgque.receive_coop).

Installation

Add broadway_pgque to your dependencies in mix.exs:

def deps do
  [
    {:broadway_pgque, "~> 0.1.0"},
    {:postgrex, ">= 0.0.0"}
  ]
end

Usage

Define a Broadway pipeline module:

defmodule MyApp.NotificationPipeline do
  use Broadway

  alias BroadwayPgQue.Producer, as: PgQueProducer

  def start_link(_opts) do
    Broadway.start_link(__MODULE__,
      name: __MODULE__,
      producer: [
        module: {
          PgQueProducer,
          repo: MyApp.Repo,
          queue: "notifications",
          poll_interval: 1000,
          max_poll_interval: 30_000,
          concurrency: 1
        }
      ],
      processors: [
        default: [concurrency: 20]
      ],
      batchers: [
        default: [batch_size: 100, batch_timeout: 2000]
      ]
    )
  end

  @impl Broadway
  def handle_message(_processor, message, _context) do
    # message.data contains the raw payload
    # message.metadata contains :id, :batch_id, :type, :retry_count, :created_at
    MyApp.Notifications.send(message.data)
    message
  end

  @impl Broadway
  def handle_batch(:default, messages, _batch_info, _context) do
    # Batch processing with automatic chunk buffering
    payloads = Enum.map(messages, & &1.data)
    MyApp.Notifications.send_bulk(payloads)
    messages
  end
end

Instant Wake-Up on Enqueue

To eliminate any latency when messages are inserted into an idle queue:

BroadwayPgQue.wake_up(MyApp.NotificationPipeline)

Custom Message Transformers

Transform raw messages before Broadway processors handle them:

defmodule MyApp.Transformer do
  def transform(raw_msg, ack_ref, ack_data, _opts) do
    decoded = Jason.decode!(raw_msg.payload)

    %Broadway.Message{
      data: decoded,
      metadata: %{id: raw_msg.msg_id, type: raw_msg.type},
      acknowledger: {ack_data.acknowledger, ack_ref, Map.put(ack_data, :msg_id, raw_msg.msg_id)}
    }
  end
end

And configure:

producer: [
  module: {
    PgQueProducer,
    repo: MyApp.Repo,
    queue: "notifications",
    transformer: {MyApp.Transformer, :transform, []}
  }
]

Configuration Reference

BroadwayPgQue.Producer uses NimbleOptions to validate all options.

Database & Connection Pooling Options

BroadwayPgQue can run 100% without Ecto. You can choose your connection pooling module or let it auto-detect:

OptionTypeDefaultDescription
:poolmodulenil (auto)Module implementing BroadwayPgQue.Pool (BroadwayPgQue.Pool.Postgrex, BroadwayPgQue.Pool.Ecto, or custom).
:connpid | atomNoneDirect Postgrex connection process PID or registered name (no Ecto required).
:connection_optskeywordNoneOptions passed to Postgrex.start_link/1 to spin up a dedicated connection/pool (no Ecto required).
:repoatomNoneShared Ecto.Repo module.
:pool_optskeyword[]Options passed to custom pool.init/1 callback.
:query_timeouttimeout15_000Query timeout in milliseconds for PgQue SQL operations.

1. Pure Postgrex (No Ecto Required!)

To run BroadwayPgQue without having Ecto in your project at all:

# Using an existing Postgrex process or pool
producer: [
  module: {
    BroadwayPgQue.Producer,
    pool: BroadwayPgQue.Pool.Postgrex,
    conn: :my_postgrex_pool, # or conn pid
    queue: "orders"
  }
]

# Or letting BroadwayPgQue manage its own dedicated Postgrex connection:
producer: [
  module: {
    BroadwayPgQue.Producer,
    queue: "orders",
    connection_opts: [
      hostname: "localhost",
      database: "my_db",
      username: "postgres",
      pool_size: 2
    ]
  }
]

2. Writing a Custom Pool Module

Implement the BroadwayPgQue.Pool behaviour to plug in your own connection pool, telemetry wrapper, or test mock:

defmodule MyApp.CustomPool do
  @behaviour BroadwayPgQue.Pool

  @impl true
  def init(opts) do
    # initialize or resolve connection state
    {:ok, opts[:conn_name]}
  end

  @impl true
  def query(conn_name, sql, params, opts) do
    Postgrex.query(conn_name, sql, params, opts)
  end
end

Polling & Scheduling Options

OptionTypeDefaultDescription
:poll_type:fixed | :adaptive:fixedPolling strategy when queue is empty: :fixed (constant poll_interval) or :adaptive (exponential backoff up to max_poll_interval).
:poll_intervalpos_integer1000Base polling interval in milliseconds when queue is empty.
:max_poll_intervalpos_integer30_000Maximum exponential backoff interval in milliseconds.
:backoff_factorfloat1.5Multiplier applied when consecutive polls yield no messages.
:max_batch_sizepos_integer100Maximum messages pulled per batch query.
:receive_mode:auto | :standard | :coop:autoUses :coop (pgque.receive_coop) if :subconsumer is provided, :standard otherwise.
:ticker_max_lagstring"0.1 seconds"PostgreSQL setting for queue ticker lag.

Acknowledgement & Error Handling Options

OptionTypeDefaultDescription
:acknowledgeratomBroadwayPgQue.AcknowledgerBroadway acknowledger module.
:on_failure:dead | :retry | function:deadAction for unhandled failed messages: move to DLQ (:dead) or retry (:retry).
:default_retry_delaypos_integer60Delay in seconds when scheduling failed messages for retry.
:transformermfa | function{BroadwayPgQue.Producer, :transform, []}Callback to transform raw database maps into Broadway.Message.

Telemetry Events

BroadwayPgQue emits standard :telemetry events for visibility:

  • [:broadway_pgque, :fetch, :start] — %{system_time: integer()}, %{queue: String.t(), consumer: String.t(), demand: integer()}
  • [:broadway_pgque, :fetch, :stop] — %{duration: integer(), count: integer()}, %{queue: String.t(), consumer: String.t(), batch_id: integer()}
  • [:broadway_pgque, :ack, :start] — %{system_time: integer()}, %{batch_id: integer(), queue: String.t(), total_count: integer(), failed_count: integer()}
  • [:broadway_pgque, :ack, :stop] — %{duration: integer()}, %{batch_id: integer(), queue: String.t()}
  • [:broadway_pgque, :ack, :exception] — %{duration: integer()}, %{batch_id: integer(), queue: String.t(), error: term()}

License

Apache License 2.0.