Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Sync Engine Part I - Single Object

docs/getting_started/watches.livemd

Sync Engine Part I - Single Object

Mix.install([
  {:phoenix_playground, "~> 0.1.0"},
  {:ecto_foundationdb, github: "foundationdb-beam/ecto_foundationdb"}
])

Intro

Using FoundationDB Watches, we can set up a mini read-path Sync Engine. It will automatically propagate new data to all mounted LiveViews with push-messaging delivered from the database directly to the LiveView process.

In this demo, we’ll update our “favorite quote” in the database and discuss the mechanism used to update the front-end with new data.

Setup Ecto

First, we set up Ecto, defining a Quote schema, and starting the Repo. For this to work, you must have foundationdb-server installed. Refer to the EctoFoundationDB documentation for installation help.

defmodule Repo do
  use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.FoundationDB

  use EctoFoundationDB.Migrator

  @impl true
  def migrations(), do: []
end

defmodule Quote do
  use Ecto.Schema

  alias Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}

  schema "quotes" do
    field(:author, :string)
    field(:content, :string)
    field(:likes, :integer, default: 0)
    field(:as_of, :naive_datetime)
    timestamps()
  end

  def changeset(quote, params \\ %{}) do
    quote
    |> Changeset.cast(params, [:author, :content, :likes, :as_of])
  end

  def like!(tenant, id) do
      Repo.transactional(tenant, fn ->
        quote = Repo.get!(Quote, id)

        quote
        |> changeset(%{likes: quote.likes+1})
        |> Repo.update!()
      end)
  end
end

# ------------------------------------------------------------------
# This section contains some set-up within Livebook that's typically
# handled instead in your application startup and config.
Application.put_env(:my_app, Repo,
  open_db: &EctoFoundationDB.Sandbox.open_db/1,
  storage_id: EctoFoundationDB.Sandbox
)

{:ok, _} = Ecto.Adapters.FoundationDB.ensure_all_started(Repo.config(), :temporary)
Repo.start_link(log: false)
# ------------------------------------------------------------------

alias EctoFoundationDB.Tenant

tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")

Next, we create a Quote in the DB. We’ll focus on how this specific Quote is rendered throughout the rest of the Livebook.

Repo.insert!(
  %Quote{
    id: "my-favorite-quote",
    author: "Philippe Verdoux",
    content: """
      Enlightenment leads to benightedness; Science entails nescience.
      """,
    as_of: NaiveDateTime.utc_now(:second)
  }, prefix: tenant, on_conflict: :replace_all)

Setup LiveView

We’re using Phoenix Playground to create a sample LiveView. The single code block below packs a bunch of things together. Here are the key takeways:

  1. In sync_one!/4, we’re initializing our mini sync engine.

    We do a standard get on the Quote with id "my-favorite-quote" that we’ve inserted above.

    In the same transaction, we’re creating a watch with label: :quote.

    Finally, we store the :quote and the list of :futures.

  2. handle_ready/2 is a LiveView hook that receives the :ready message from the watch future, which is delivered directly from the database layer. We use Repo.assign_ready/3 to update the LiveView assigns map. Whenever "my-favorite-quote" changes in the DB, this function will be called automatically. Also, we provide watch?: true to continue listening for updates.

  3. handle_event/3 receives the "like" event, and uses an FDB transaction to add 1 to the count of likes. Notice we choose not to update the assigns, for demonstration purposes.

  4. sync_one!/4 and handle_ready/2 both contain nothing specific to the Quote schema. This is a general purpose approach that can be re-used for any other schema types.

defmodule DemoLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do

    tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")

    {:ok, socket
      |> assign(tenant: tenant)
      |> sync_one!(Quote, :quote, "my-favorite-quote")
    }
  end

  # 1. Initialize our mini Sync Engine
  defp sync_one!(socket, schema, label, id) do
    {struct, futures} = Repo.transactional(socket.assigns.tenant,
      fn ->
        struct = Repo.get!(schema, id)
        future = Repo.watch(struct, label: label)
        {struct, [future]}
      end)

    socket
    |> assign(label, struct)
    |> assign(futures: futures)
    |> attach_hook({:assign_ready, schema, label, id}, :handle_info, &handle_ready/2)
  end

  # 2. LiveView server hook that receives a message from the database layer
  #    and updates the assigns
  defp handle_ready({ref, :ready}, socket) do

    %{assigns: assigns} = socket

    {new_assigns, futures} = Repo.assign_ready(
        assigns.futures,
        [ref],
        watch?: true,
        prefix: assigns.tenant
      )
    {:halt, socket
            |> assign(new_assigns)
            |> assign(futures: futures)}
  end

  def render(assigns) do
    ~H"""
    
      

My Favorite Quote

as of <%= @quote.as_of %> UTC
<%= @quote.content %>

- <%= @quote.author %>

Likes: <%= @quote.likes %> 👍

h1 { text-align: center; } h3 { text-align: right; } h6 { text-align: right; } p { text-align: center; } .content { max-width: 500px; margin: auto; } """
end # 3. Receive the "like" event and update the DB def handle_event("like", _params, socket) do Quote.like!(socket.assigns.tenant, socket.assigns.quote.id) {:noreply, socket} end end PhoenixPlayground.start(live: DemoLive)

With the block above evaluated, you can now open a web browser to http://localhost:4000. We suggest you keep this open side-by-side with this Livebook if possible, so that you can watch the updates in real-time. You can even open several tabs to ensure that all tabs are indeed in sync.

You can click the Like button and observe that the value changes. Notice that we are not pushing this value to the assigns explicitly. It’s being handled automatically in our mini sync engine in handle_info.

Updating My Favorite Quote

Your favorite quote might change from moment to moment. We’ll write to the database with your current favorite quote. Because of the FDB watch, the LiveView will always render the most up-to-date content.

tenant = Tenant.open!(Repo, "experiment-with-watches-in-liveview")

defmodule ChangeFavoriteQuote do
  @id "my-favorite-quote"

  def to(tenant, author, content) do
    Repo.transactional(tenant, fn ->
      Repo.get(Quote, @id)
      |> Quote.changeset(%{
        author: author,
        content: content,
        as_of: NaiveDateTime.utc_now(:second),
        likes: 0
      })
      |> Repo.update()
      end)
  end
end
ChangeFavoriteQuote.to(
  tenant,
  "Duke Leto Atreides",
  """
  Once you have explored a fear, it becomes less terrifying. \
  Part of courage comes from extending our knowledge.
  """)
ChangeFavoriteQuote.to(tenant, "Captain America", "I can do this all day.")

Takeaways

The user of our web app is always kept up-to-date with the most recent favorite quote via the combined power of LiveView and our database. In some Phoenix applications, this is done via Phoenix.PubSub, but to do so requires setting up a separate PubSub resource and handling the messages for each kind. In this demo we instead used a feature from FoundationDB, and in doing so, we created a mini Sync Engine. We used a self-contained and generalizable approach composed of the EctoFoundationDB transaction and the LiveView server hook.

The main takeaway from this Livebook is that there is no visible dispatcher. Each process of our application can choose to register a watch, and it will be notified directly as needed from the database itself.