Skip to content

refactor!: make Client, Consumer, and Producer the public API - #164

Merged
efcasado merged 133 commits into
mainfrom
refactor/application-behavior
Aug 3, 2026
Merged

refactor!: make Client, Consumer, and Producer the public API#164
efcasado merged 133 commits into
mainfrom
refactor/application-behavior

Conversation

@efcasado

@efcasado efcasado commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

This PR makes Pulsar.Client, Pulsar.Consumer, Pulsar.Producer, and Pulsar.Reader the library's public boundaries. Every broker connection, consumer, producer, and temporary reader consumer now lives below the client whose connection context it uses.

It also replaces duplicated consumer and producer supervision with one shared topology, initializes topic metadata asynchronously, supports multiple isolated clients, and makes configuration explicit at the client or resource boundary.

This is a breaking change. Closes #163 and #165.

Motivation

Previously, the :pulsar application owned clients and resources itself. That could run consumer callbacks before host dependencies were ready, block application startup on network metadata, discard resource startup failures, and left no clean way to supervise multiple independent clients. Consumers and producers also depended on client registries without living under that client.

The host application now supervises Pulsar.Client; the client owns its brokers, registries, consumers, and producers.

Public API boundaries

Module Responsibility
Pulsar.Client Owns one connection context and its runtime resources
Pulsar.Consumer Starts, stops, and controls logical consumers
Pulsar.Producer Starts, stops, and publishes through logical producers
Pulsar.Reader Provides stream-based, non-durable reading through a temporary consumer

Runtime operations moved from Pulsar to the module that owns them. The former consumer and producer GenServers are internal workers; public operations target a registered name or the stable root returned by start. Pulsar.Client.consumers/1 and Pulsar.Client.producers/1 therefore return one root per logical resource, independent of partition and worker count.

Acknowledgement remains worker-specific because a message contains the broker-side consumer id that delivered it:

Pulsar.Consumer.ack(consumer_pid, message_id)

Ownership and lifecycle

Applications supervise one or more clients and may declare resources on each client:

children = [
  {Pulsar.Client,
   host: "pulsar://localhost:6650",
   producers: [[topic: audit_topic, name: :audit]],
   consumers: [[topic: orders_topic,
                subscription_name: "order-service",
                callback_module: MyApp.OrderHandler]]}
]
Resource Creation Restoration after client or branch restart
Declared Client :consumers or :producers option Recreated by the client branch
Runtime Pulsar.Consumer.start/1 or Pulsar.Producer.start/1 Restored by its caller
Reader Pulsar.Reader.stream/2 Removed when the stream ends

Starting establishes ownership and registration, not readiness. Metadata discovery and worker initialization continue asynchronously; callers may use await_ready/2 or handle {:error, :not_found} and {:error, :not_ready}. Consumer and producer branches recover independently, while broker infrastructure remains above both resource branches.

Shared topology and discovery

The duplicated ConsumerGroup, ProducerGroup, PartitionedConsumer, and PartitionedProducer implementations are replaced by:

  • Pulsar.Topology, the stable root for one logical resource;
  • Pulsar.Topology.Discovery, which initializes and reconciles its shape;
  • Pulsar.Topology.Resolver, which performs broker metadata and owner lookups;
  • Pulsar.Topology.Group, which owns the workers for one topic or partition.

A non-partitioned resource has one group; a partitioned resource has one group per partition. Discovery retries initial failures without blocking application startup, optionally polls for partition growth, and separately reconciles known groups without broker I/O. Terminal worker failures can therefore leave a stable but degraded root that a later local pass can recover.

Facade operations inspect topology groups rather than calling Discovery, so metadata I/O never serializes publishing or flow control. Producer routing retains unavailable partition slots and switches directly between complete partition widths during growth, avoiding temporary keyed remaps.

Reader

Pulsar.Reader creates a temporary non-durable consumer below an existing client and waits for its topology and workers. :startup_timeout bounds that initialization separately from the stream's message inactivity :timeout.

Configuration

Pulsar.Application and Pulsar.Config are removed. Starting the :pulsar application starts no clients or resources.

Configuration Owner
Authentication, connection, socket, frame, ping, cleanup, and request options Pulsar.Client
Startup delay, jitter, worker count, and partition polling Pulsar.Consumer or Pulsar.Producer
Broker option defaults Pulsar.Broker.Options
Retry and reconnect backoff Internal policy

Options are validated at the public boundary and passed explicitly to owned processes.

Pre-existing bugs fixed

These bugs existed on main before this branch. They are listed separately so they do not disappear inside the architectural refactor.

  • Stopping a runtime consumer or producer did not remove it. Calling Supervisor.stop/1 stopped a permanent child, so its DynamicSupervisor immediately restarted it. The public stop functions now terminate the child through its owner.

  • A producer could disappear permanently after exhausting its restart intensity. Producer groups were registered as :transient, so a group exiting with :shutdown after exceeding its intensity was not restarted. Resource roots are now permanent children.

  • The default startup delay defeated restart-intensity limits. The previous one-second delay plus jitter was wider than the restart-counting period, allowing a permanently failing resource to restart forever. Startup delay and jitter now default to zero and remain opt-in.

  • Duplicate declared resources were silently ignored. Startup results were discarded, so a duplicate registered name could be treated as successful. Client validation now rejects declarations that resolve to the same public name before startup.

  • Producers in the same group shared one topic epoch. Workers shared the group name while Pulsar.Producer.EpochStore keys epochs by producer name. Workers now receive distinct <group>-<n> names.

  • Multiple clients could not use their documented child specs in one static tree. Every client used the same child-spec id. Client ids are now keyed by client name; consumer and producer ids are similarly keyed and namespaced.

  • Publishing through a stale producer pid could exit the caller. Tree inspection happened outside the exit-catching path. Publishing now returns {:error, {:producer_died, reason}}.

  • Partition growth could temporarily remap keyed messages through an intermediate modulus. Producers hashed using the number of partition groups already started, so a 4-to-6 expansion could briefly route with modulus 5 or leave that modulus in place after a partial start failure. Reconciliation now adds higher indexes first, and routing switches directly from the old contiguous width to the new one only after every intervening partition exists.

  • Publishing could treat :restarting as a partition-group pid. Routing now ignores restarting children and returns {:error, :no_producers_available} while preserving partition selection.

  • Operations against a missing or restarting client could exit instead of returning an error. Registry lookup, broker traversal, service discovery, and runtime resource startup now preserve the public error contracts while client branches are absent, shutting down, or restarting.

  • Stopping a supervised client could erase the replacement client's broker options. Pulsar.Client.stop/2 cleared per-client connection settings after the old process exited, racing a parent supervisor that had already restarted it. Cleanup now happens before shutdown, so the replacement always republishes the final value.

  • Pulsar.Producer.EpochStore.get/4 raised when the client's ETS table did not exist. Reads now return :error, matching the function specification and the write/delete APIs.

  • Broker defaults differed depending on how the broker was started. Broker option validation and defaults now have one owner.

  • The reconnect ceiling disagreed with the documented default. Reconnect backoff now has one internal ceiling.

  • The manual-flow type contract allowed zero permits even though the implementation rejected them. Consumer flow-control specs and facade guards now consistently require a positive permit count.

Breaking changes and migration

Before After
Elixir ~> 1.14 Elixir ~> 1.15 (required for topology group auto-shutdown)
config :pulsar, ... starts resources Declare resources on {Pulsar.Client, opts}
Pulsar.start/1, start_link/1, start_client/1 Supervise Pulsar.Client or call Pulsar.Client.start_link/1
Pulsar.stop/1 Pulsar.Client.stop/2
Pulsar.start_broker/2, lookup_broker/2, stop_broker/2 Corresponding Pulsar.Client functions
Pulsar.start_consumer/4 Pulsar.Consumer.start/4
Pulsar.stop_consumer/2 Pulsar.Consumer.stop/2
Pulsar.lookup_consumer/2, Pulsar.get_consumers/2 Removed; pass a name or stable root to Pulsar.Consumer, or list logical roots with Pulsar.Client.consumers/1
Pulsar.ack/3, nack/3, send_flow/3 Corresponding Pulsar.Consumer functions
Pulsar.start_producer/2 Pulsar.Producer.start/2
Pulsar.stop_producer/2 Pulsar.Producer.stop/2
Pulsar.lookup_producer/2, Pulsar.get_producers/2 Removed; pass a name or stable root to Pulsar.Producer, or list logical roots with Pulsar.Client.producers/1
Pulsar.send/3 Pulsar.Producer.send/3
Reader.stream(topic, host: ...) Start a client separately and select it with :client

Additional migration notes:

  • Consumers and producers known at boot belong in their client's declaration lists, not beside the client in the host tree.
  • Pulsar.Client.start_link/1 and resource start calls do not wait for readiness.
  • Runtime resources are not restored after their branch or client restarts.
  • Consumer and Producer no longer expose registry lookup, partition counts, groups, or worker enumeration; those are internal topology concerns.
  • Consumer.ack/2 and Consumer.nack/2 require the worker pid that received the message; a logical consumer name or root is ambiguous. Callback code can capture self() and pass it to asynchronous work.
  • Reader no longer creates clients from :host, :auth, or :socket_opts.
  • Application-environment tuning is replaced by explicit client and resource options.
  • Consumer and producer restart intensity is shared at 100 restarts in 60 seconds.
  • Broker-visible worker names change to <group>-<n>.

Architecture guide

docs/architecture.md describes the registry-aware ownership tree, stable roots, asynchronous readiness, recovery model, and contributor implementation notes for reconciliation and routing.

Validation

The unit and integration suites cover lifecycle, recovery, asynchronous discovery, routing, Reader behavior, and missing-process races. Formatting, Credo, Dialyzer, documentation, and doctests pass.

Follow-ups

The following remain outside this PR:

  • Brutally killing the root client with :kill can race the termination of its named descendants before the host supervisor restarts it. This predates the branch and does not occur during graceful shutdown or normal supervisor failure propagation.
  • Remove positional arguments from Pulsar.Producer.start/... and Pulsar.Consumer.start/....
  • Reader streams do not yet surface worker loss after initialization. Transparent restart of a non-durable subscription could replay or skip messages, so explicit failure semantics are tracked in issue #168.

@efcasado
efcasado force-pushed the refactor/application-behavior branch from 196c231 to 6742b88 Compare July 26, 2026 16:05
@efcasado
efcasado marked this pull request as ready for review July 26, 2026 17:19

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/efcasado/pulsar-elixir/blob/c4e5b0a6db84cfbf45449b04068f464318645025/lib/pulsar.ex#L180
P2 Badge Update the Reader guide to start Pulsar.Client

Removing Pulsar.start_link/1 leaves the published docs/reader.md guide using {Pulsar, host: ..., name: ...} as a supervision-tree child at line 57. Pulsar is now only the application callback and has no matching child spec, so following that external-client example fails during supervisor initialization; replace it with {Pulsar.Client, ...} as in the updated module documentation.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/producer.ex Outdated
Comment thread lib/pulsar/config.ex
Comment thread lib/pulsar/producer.ex
Same as `start/1`, with the topic given positionally.
"""
@spec start(String.t(), keyword()) :: DynamicSupervisor.on_start_child()
def start(topic, opts) when is_binary(topic), do: start(Keyword.put(opts, :topic, topic))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the one-argument producer start form

The previous public API allowed Pulsar.start_producer(topic) because its options defaulted to [], but the positional replacement requires an explicit second argument. Consequently, the direct migration Pulsar.Producer.start(topic) selects the keyword-list start/1 clause and raises FunctionClauseError; give opts the same empty-list default retained by Pulsar.Consumer.start/4.

Useful? React with 👍 / 👎.

Comment thread lib/pulsar/client.ex Outdated
Comment thread lib/pulsar/producer.ex Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b16bec7d5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/client.ex Outdated
Comment thread lib/pulsar/consumer.ex Outdated
Comment thread lib/pulsar/consumer.ex Outdated
Comment thread lib/pulsar/reader.ex Outdated
Comment thread README.md Outdated
Comment thread lib/pulsar/producer.ex Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7dfe6f06e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/producer.ex Outdated
Comment thread lib/pulsar/producer.ex Outdated
@efcasado
efcasado force-pushed the refactor/application-behavior branch from a27a449 to 376af97 Compare July 26, 2026 20:08

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 376af9764e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/chunking.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3c60631ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/producer.ex Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e81ba9708

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/consumer.ex Outdated
Comment thread README.md Outdated
Comment thread docs/chunking.md Outdated
Comment thread lib/pulsar/consumer/callback.ex Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a14f23347

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/topology.ex
Comment on lines +115 to +116
:initializing -> {:error, :not_ready}
{:ready, _shape} -> :ok

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include worker registration in the readiness barrier

When metadata discovery succeeds but a producer worker is still registering—or subsequently receives a terminal response such as :IncompatibleSchema—this branch returns :ok solely from Discovery's topology status, even though the worker can immediately stop in Producer.Worker.handle_continue/2 and no usable producer exists. A caller relying on Pulsar.Producer.await_ready/2 can therefore proceed and have its first publish fail with :producer_died despite the successful barrier. Fresh evidence beyond the earlier bingo-example comment is that the same topology-only check is the public await_ready/2 implementation; readiness should include successful worker initialization or surface its failure.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb50a9f1d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pulsar/client.ex
Comment on lines 448 to 452
try do
Supervisor.stop(client_name, :normal, timeout)
catch
:exit, _ -> :ok
:exit, _reason -> :ok
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Erase broker options before stopping the client

When applications create and stop dynamically named clients, init/1 stores each client's full broker configuration in :persistent_term, but this stop path no longer erases it. Every stopped client therefore permanently retains its options—including authentication and TLS data—and repeated transient clients steadily grow global memory; erase the entry before shutdown so a supervising parent can safely republish it during restart.

Useful? React with 👍 / 👎.

Comment thread lib/pulsar/consumer.ex Outdated
Comment on lines +117 to +120
case Topology.kind(consumer) do
:topology ->
client = Keyword.get(opts, :client, :default)
Topology.remove(consumer, Client.resource_supervisor(:consumers, client))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the topology belongs to a consumer before stopping it

When a producer root PID is accidentally passed to Pulsar.Consumer.stop/2, Topology.kind/1 identifies it only as a generic topology, and Topology.remove/2 falls back from the consumer supervisor to the PID's actual producer supervisor. The call therefore returns :ok after terminating a producer instead of the documented {:error, :not_found}; the inverse happens in Pulsar.Producer.stop/2, so PID operations must distinguish consumer and producer roots before removal.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@efcasado
efcasado merged commit b353b19 into main Aug 3, 2026
8 checks passed
@efcasado
efcasado deleted the refactor/application-behavior branch August 3, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Don't start user-configured clients, consumers and producers from the application callback

1 participant