Listen to Database Changes with Postgres Triggers and Elixir

- 8 min read

Welcome back to another blog post :) Before we begin, one question:

Why do cows have hooves instead of feet?
Because they lactose!

Alright, now let’s dive into today’s topic: How to listen to database changes using Postgres triggers and Elixir

Why listen at all?

In almost all codebases, you have subsequent processes depending on previous processes. Imagine a sign-up flow for new users. After a user signs up, you might want to start sending marketing emails periodically. This is called a downstream process. The sign-up to the marketing emails depends on the user’s registration. When your software is still simple enough, you usually get away with calling these processes in sequence. Your code might look something like this:

def register_user(params) do  with {:ok, user} <- Repo.create_user(params),
       :ok <- sign_up_for_marketing_emails(user) do
    :ok
  end
end

In this simple case, you only have one downstream process: the sign-up for the marketing emails. But what if your software becomes more complex and you add more and more such downstream processes? Your register_user/1 function will grow and grow. Maybe your company grows as well and adds more dev teams. That’s great, but now you have more teams adding their own downstream calls to register_user/1, making them all dependent on each other.

Imagine that one of the many calls fails. Usually, the entire user registration will roll back, undoing the changes in every team that placed their downstream call before the failed one. In the worst case, you might have an incomplete registration, where some calls succeed and others fail and you didn’t roll back properly. Now, the teams with failed calls need to somehow fetch the missing user data and reconcile it with everything that might have happened after the registration. It’s simply a mess.

A frequent solution to untangle these dependencies is to implement an event system. Phoenix.PubSub is such a system for example. Event systems have many advantages (and disadvantages), but in this case, they help us to reverse the code dependency between upstream and downstream processes. Without an event system, register_user/1 has to call sign_up_for_marketing_emails/1 directly. register_user/1 is aware of the downstream process and it needs to change if the downstream process changes, for example when we need to provide more info, like which marketing emails the user subscribed to and which ones they didn’t. Our upstream code, register_user/1, depends on the downstream process, sign_up_for_marketing_emails/1.

Now, with an event system, we can reverse that dependency. Our register_user/1 call could simply broadcast an event using PubSub.broadcast/4. Our downstream process can then subscribe and react to the events. Now, our upstream code is unaware of the downstream process. It doesn’t care whether it succeeds or fails or if it changes. Instead, the downstream code now depends on register_user/1 to publish the necessary data. That’s it. If we want to change the marketing email sign-up, we simply handle the received data differently in sign_up_for_marketing_emails/1. No need to change register_user/1. Perfect!

How to listen?

I mentioned Phoenix.PubSub before and if I’m implementing an Elixir application, that would be my go-to event system if I need to send and react to basic notifications. If I need more advanced systems, Kafka, RabbitMQ, or Amazon's SQS would come to mind. But there’s another, low-effort solution that you probably already have in your tech stack: Postgres.

Postgres offers quick and simple Notifications that can help you react to changes in your database without much overhead. They are particularly interesting if you can’t use Phoenix’s PubSub, for example, if another non-Elixir application also makes changes to your database.

Let’s see how you can set up Postgres notifications in your Phoenix app.

Like what you read? Sign up for more!

The Migration

The first step is to add the notifications using Postgres SQL. The migration below adds a trigger to a table called appointments that sends a notification whenever an appointment is canceled or uncanceled. Let’s have a look:

defmodule Demo.Repo.Migrations.AddAppointmentCanceledTrigger do  use Ecto.Migration

  def up do
    execute """
    CREATE OR REPLACE FUNCTION notify_canceled_changed()
      RETURNS trigger AS $trigger$
      DECLARE
        payload TEXT;
      BEGIN
        IF (TG_OP = 'UPDATE') AND (OLD.canceled != NEW.canceled) THEN
          payload := json_build_object('id',OLD.id,'old',row_to_json(OLD),'new',row_to_json(NEW));
          PERFORM pg_notify('appointments_canceled_changed', payload);
        END IF;

        RETURN NEW;
      END;
      $trigger$ LANGUAGE plpgsql;
    """

    execute """
    CREATE TRIGGER appointments_canceled_changed_trigger
      AFTER UPDATE ON appointments FOR EACH ROW
      WHEN ( OLD.canceled IS DISTINCT FROM NEW.canceled )
      EXECUTE PROCEDURE notify_canceled_changed();
    """
  end

  def down do
    execute """
    DROP TRIGGER appointments_canceled_changed_trigger ON appointments;
    """

    execute """
    DROP FUNCTION notify_canceled_changed();
    """
  end
end

That’s a lot of SQL, but in short, the migration first adds a function called notify_canceled_changed() which sends a notification to the channel appoiments_canceled_changed with the id of the appointment and the before and after state of the appointment encoded as JSON. We add the function as a trigger to the appointments table using CREATE TRIGGER .... The function is only executed if the appointment is updated, so not inserted or deleted, and only if the appointment.canceled boolean field has changed.

In our notify_canceled_changed function, we double-check these validations as a precaution (i.e. the record is updated and canceled has changed). We wouldn’t need to though since Postgres will execute this function only if these requirements are met. But it never hurts to double-check!

You might have noticed that we don’t use an EVENT TRIGGER here. The difference between a TRIGGER and an EVENT TRIGGER is that the latter handles database-wide events (e.g. CREATE TABLE) whereas the former handles events specific to a given table (e.g. INSERT|UPDATE|READ|DELETE| row). We are only interested in changes to the appointments table which is why we use a simple TRIGGER.

Another detail you might have spotted is that we execute the trigger after a row was updated with AFTER UPDATE ON. For our use-case, the distinction between BEFORE and AFTER update doesn’t matter, but if you wanted to change the new data before it is written to the row, you would need to use the BEFORE UPDATE statement. Read this for more details on the difference between the two.

If you want more examples of triggers, here are some.

Now with the migration in place, run mix ecto.migrate to add the function and trigger to your database. However, if you run your application now and update an appointment, you won’t see anything. That’s because we’re not yet listening to such events from our Elixir application. Let’s change that.

The Listener

We will use the Postgrex.Notifications module to listen to and handle messages from Postgres. Here’s our listener:

defmodule Demo.DatabaseListener do  use GenServer

  @channel "appointments_canceled_changed"

  def start_link(init_args) do
    GenServer.start_link(__MODULE__, [init_args], name: __MODULE__)
  end

  def init(_args) do
    repo_config = Demo.Repo.config()

    {:ok, pid} = Postgrex.Notifications.start_link(repo_config)
    {:ok, ref} = Postgrex.Notifications.listen(pid, @channel)

    {:ok, {pid, ref}}
  end

  def handle_info({:notification, _pid, _ref, @channel, payload}, state) do
    payload = Jason.decode!(payload)
    IO.puts("Received a notification for appointment #{payload["id"]}")
    IO.puts(payload)

    {:noreply, state}
  end
end

We created a GenServer for our listener with its own message queue to handle incoming Postgres messages. Be aware that this listener can easily become a bottleneck if you have lots of messages. If you can’t handle the messages quickly enough, the message queue will fill up and crash your application. If you’re worried about this case, you could create one listener per channel or use a PartitionSupervisor to start more handlers and spread out the work.

Our listener is pretty straightforward. First, we spawn another process that connects to Postgres using our Repo config. Then, we start listening to that process which means that we’ll receive any Postgres messages it might catch. Whenever a notification comes in, our handle_info/2 callback will handle it. In this case, we simply decode its JSON payload and log it to the console.

The last step is to start our listener whenever our application starts. Open your lib/application.ex and add the listener to the supervisor’s children, like this:

# lib/application.exdef start(_type, _args) do
  children = [
    # Other modules
    Demo.Repo,
    # Make sure to start the listener AFTER your Repo.
    # Otherwise, your listener won't connect.
    Demo.DatabaseListener
  ]

  opts = [strategy: :one_for_one, name: Demo.Supervisor]
  Supervisor.start_link(children, opts)
end

And that’s it! Now, whenever we start our application, we establish a link to Postgres and listen to messages on the given channel. If such a message comes in, our handle_info/2 will log it. Let’s try it out!

The Test

Now with everything in place, we can test-run the setup. Let’s create and update an appointment:

iex> {:ok, a} = Demo.Appointments.create_appointment(%{canceled: false}){:ok, %Appointment{...}}
iex> {:ok, a} = Demo.Appointments.update_appointment(a, %{canceled: true})
{:ok, %Appointment{...}}
"Received a notification for appointment 1"
%{
  "id" => 1,
  "old" => %{
    "id" => 1,
    "canceled" => false,
    "inserted_at" => "2023-02-11T17:36:56",
    "updated_at" => "2023-02-11T17:37:03"
  },
  "new" => %{
    "id" => 1,
    "canceled" => true,
    "inserted_at" => "2023-02-11T17:36:56",
    "updated_at" => "2023-02-11T17:44:48"
  }
}

After updating the canceled-flag of our appointment, we received a message from Postgres! It works! Yey!

Conclusion

And that’s it! I hope you enjoyed this article! If you have questions or comments, let’s discuss them on BlueSky. Follow me on BlueSky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until the next time! Cheerio 👋

Liked this article? Sign up for more