Skip to content

feat: add function and module-based topic generation to PubSub #2139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

barnabasJ
Copy link
Contributor

@barnabasJ barnabasJ commented Jun 13, 2025

This addresses GitHub issue #2081

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

@barnabasJ barnabasJ self-assigned this Jun 13, 2025
@barnabasJ barnabasJ added the enhancement New feature or request label Jun 13, 2025
@barnabasJ barnabasJ force-pushed the feature/relationship-based-pubsub-topics branch from 03bbe74 to 63ff8dc Compare June 13, 2025 23:04
@zachdaniel
Copy link
Contributor

So, I think there is one particular thing we should do before we support this, to solve for bulk actions, which is that we'd add a load callback. However, what we need to do for that is actually pretty complicated. Specifically, we can't clobber existing load statements on the data. The "trick" would be to create a new calculation for each notifier that requires loads, and dynamically add it for each of those notifiers, and have that dynamic calculation return the loads required by the notifier as a map. This would look kind of like the LoadRelationship calculation. The notifier would have an interface like this:

@callback required_loads :: %{term() => load_statement()}

The key of that map would be to disambiguate for the notifier if multiple separate notifications trigger on that action.

So in the read action, we'd add calculations like Ash.Query.Calculation.new({:notifier_dependencies, Notifier, key}, {ABuiltInModuleCalculationForThis, load: [load]}) for each notifier/key combination, that returns a map where the key is each load in load. Then, before passing the data into the notifier, we'd merge those loads with/ the calculation data.

Writing this all up has pointed out how complex the problem space is 😆. However, it is the pattern we'd need for notifiers to automatically load their dependencies. We can potentially side-line this for now, but I worry about having this pattern in place in a way that plays so poorly with bulk actions.

…tion

Add optional required_loads/0 callback to TopicGenerator behavior to enable
automatic relationship preloading and eliminate N+1 queries in bulk operations.

- Add required_loads/0 callback to TopicGenerator behavior
- Create Helpers module to extract loads and add them to changesets
- Integrate load injection into create and bulk create actions
- Add comprehensive tests demonstrating N+1 elimination
- Maintain full backwards compatibility with existing topic generators

This addresses the performance issue where bulk operations with relationship-
dependent topic generation would trigger individual loads per notification.
Topic generators can now declare their load requirements and the system
automatically preloads the necessary relationships.

Example usage:
```elixir
defmodule MyTopicGenerator do
  use Ash.Notifier.PubSub.TopicGenerator

  @impl true
  def required_loads, do: [author: :followers]

  @impl true
  def generate_topics(notification, _opts) do
    # Relationships are already loaded
    followers = notification.data.author.followers
    Enum.map(followers, &"feed:#{&1.id}")
  end
end
```
| [
String.t()
]
@callback required_loads() :: term()
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be a callback on Ash.Notifier I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, nvm. This makes sense here. But my other comment about having a callback on the notifier itself to drive this is the thing.

@@ -21,6 +21,7 @@ defmodule Ash.Actions.Create do
else
{changeset, opts} = Ash.Actions.Helpers.set_context_and_get_opts(domain, changeset, opts)
changeset = Helpers.apply_opts_load(changeset, opts)
changeset = Ash.Notifier.PubSub.Helpers.add_topic_generator_loads(changeset)
Copy link
Contributor

Choose a reason for hiding this comment

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

We shouldn't have behavior for a specific notifier here, it should be calling a callback on all of the notifiers for this resource and action.

Ash.Changeset.load(changeset, load_list)

%Ash.Query{} = query ->
Ash.Query.load(query, load_list)
Copy link
Contributor

Choose a reason for hiding this comment

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

We can't just load the things here, because the caller of the action could request the same load key but with different parameters which can lead to non deterministic behavior. This is what I was getting at with needing to add a custom calculation.

@barnabasJ barnabasJ marked this pull request as draft June 15, 2025 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants