FLAME Framework

repository·main·Indexed 22 days ago

https://github.com/phoenixframework/flame

A framework for running modular parts of an Elixir application on short-lived, elastic infrastructure. FLAME treats the entire application as a 'lambda', allowing developers to offload heavy tasks to temporary copies of the app with full access to the environment and database. It provides interfaces for synchronous calls (FLAME.call/3), asynchronous casts (FLAME.cast/3), and remote child processes (FLAME.place_child/3), with built-in backends for local development and Fly.io.

Tokens
6K
Snippets
19
Records
26
Agent score
78%

What's inside FLAME

  1. What is the FLAME pattern?

    main
    FLAME (Fleeting Lambda Application for Modular Execution) is a pattern where you treat your entire application as a lambda. Instead of just running a function, you run a modular part of your application inside a temporary, short-lived copy of the app on elastic infrastructure. This allows you to scale specific, heavy workloads (like video processing or heavy computations) without scaling your entire main application cluster.
  2. Configure a FLAME Pool

    main

    To enable elastic scaling, start a FLAME.Pool in your application's supervision tree (typically in lib/my_app/application.ex). The pool manages scaling runners up and down and monitors remote resources.

    Common configuration options include:

    • name: The name of the pool.
    • backend: The backend used for provisioning (e.g., FLAME.FlyBackend).
    • min: Minimum number of runners.
    • max: Maximum number of runners.
    • max_concurrency: Maximum concurrent executions.
    • idle_shutdown_after: Time in milliseconds after which an idle runner is shut down.
    • log: Logging level.
    children = [
      {FLAME.Pool,
       name: MyApp.SamplePool,
       backend: FLAME.FlyBackend,
       min: 0,
       max: 10,
       max_concurrency: 5,
       idle_shutdown_after: 30_000,
       log: :debug}
    ]
  3. Install FLAME

    main

    Add :flame to your project's dependencies in your mix.exs file.

    Note for Erlang/OTP 26 and earlier: You must also include :jason (version >= 0.0.0) as a dependency for compatibility.

    defp deps do
      [
        # For Erlang/OTP 26 and earlier, you also need Jason
        # {:jason, ">= 0.0.0"},
        {:flame, "~> 0.5"}
      ]
    end
  4. Manage remote nodes with FLAME.Runner

    main

    A FLAME.Runner is responsible for booting a new node and executing concurrent functions on it. It is typically managed via a FLAME.Pool of runners. When a caller exits or crashes, the remote node is automatically terminated (e.g., via FLAME.Terminator when using FLAME.FlyBackend).

    To use a runner, you typically follow this lifecycle:

    1. Start the runner with a specific backend.
    2. Boot the remote node.
    3. Execute functions using call/3 or cast/3 (though call/3 is the primary entry point for synchronous execution).
    4. Shutdown the runner when finished.
    {:ok, runner} = Runner.start_link(backend: FLAME.FlyBackend)
    :ok = Runner.remote_boot(runner)
    Runner.call(runner, fn -> :operation1 end)
    Runner.shutdown(runner)
  5. How FLAME Pools manage concurrency and scaling

    main

    FLAME Pools are designed for elastic scaling based on demand and concurrency limits:

    1. Scaling Up: When a call or place_child is made and no runners have available capacity (up to :max_concurrency), the pool checks if it can grow. If the current runner count is less than :max, it initiates an asynchronous boot of a new runner.
    2. Concurrency Control: Each runner has a :max_concurrency limit. If all runners are busy, new requests are queued in a WaitingState queue.
    3. Scaling Down: Runners that become idle for a period exceeding :idle_shutdown_after are shut down.
    4. Scale to Zero: By setting :min to 0, the pool can shut down all runners when there is no demand, saving resources.
    5. Resource Tracking: If :track_resources is enabled, the pool monitors returned results for any data implementing the FLAME.Trackable protocol. This ensures the remote node does not terminate while these resources are still in use.
  6. Handle remote node lifecycle messages in a FLAME backend

    main

    When a remote node is booted, a FLAME.Terminator process starts automatically on that node. This process communicates lifecycle events back to the parent node via the handle_info/2 callback in your backend implementation.

    Your backend can react to these specific message patterns:

    1. Node Booted: When the remote terminator starts, it sends: {ref, {:remote_up, remote_terminator_pid}} Note: ref is the reference generated by the backend and encoded into the FLAME.Parent.encode/1 string.

    2. Graceful Shutdown: When the remote terminator is shutting down gracefully, it sends: {ref, {:remote_shutdown, :idle}}

    Implementing handle_info/2 allows your backend to manage the state of provisioned instances as they come online or go offline.

    @impl true
    def handle_info({ref, {:remote_up, terminator_pid}}, state) do
      # Handle the new remote node being ready
      {:noreply, %{state | remote_nodes: Map.put(state.remote_nodes, ref, terminator_pid)}}
    end
    
    @impl true
    def handle_info({ref, {:remote_shutdown, :idle}}, state) do
      # Handle the remote node shutting down
      {:noreply, %{state | remote_nodes: Map.delete(state.remote_nodes, ref)}}
    end
  7. Use FLAME.LocalBackend for development and testing

    main

    The FLAME.LocalBackend is a implementation of the FLAME.Backend behaviour designed for local execution on the current node. It is primarily intended for development and testing environments where you do not need to distribute tasks to remote servers.

    Configuration

    You can configure the backend using the init/1 function. It accepts an opts keyword list and can also be configured via the application environment using the :flame application with the FLAME.LocalBackend module as the key.

    Required Options:

    • :terminator_sup: A supervisor (typically a DynamicSupervisor) used to manage the lifecycle of terminators created during remote_boot/1.
    # Example configuration via Application environment
    Application.put_env(:flame, FLAME.LocalBackend, [terminator_sup: MySupervisor])
    
    # Or passing options directly to init/1
    # (Note: init/1 is called internally by the Flame supervisor/manager)
  8. Track resources with FLAME.Trackable

    main
    If you allocate long-lived resources in a FLAME node, the node might terminate before the resource is finished. By using the :track_resources option in FLAME.call/3, FLAME will traverse the returned result looking for data that implements the FLAME.Trackable protocol. FLAME will then ensure the node does not terminate until all tracked PIDs have terminated.
  9. Conditionally start services in FLAME runners

    main

    Because FLAME nodes clone your entire application, you may want to disable certain services (like Phoenix endpoints or large database connection pools) when the node is running as a FLAME child rather than the main application node. Use FLAME.Parent.get/0 to detect this state.

    Example: Conditional Endpoint

    def start(_type, _args) do
      flame_parent = FLAME.Parent.get()
    
      children = [
        ...,
        {FLAME.Pool, name: Thumbs.FFMpegRunner, ...},
        !flame_parent && ThumbsWeb.Endpoint
      ]
      |> Enum.filter(& &1)
    
      Supervisor.start_link(children, [strategy: :one_for_one, name: Thumbs.Supervisor])
    end

    Example: Conditional Database Pool Size

    pool_size = 
      if FLAME.Parent.get() do
        1
      else
        String.to_integer(System.get_env("POOL_SIZE") || "10")
      end
    
    config :thumbs, Thumbs.Repo, pool_size: pool_size
  10. FLAME execution interfaces

    main

    FLAME provides three primary interfaces for elastically scaled operations:

    • FLAME.call/3: Used for synchronous calls where you wait for the result.
    • FLAME.cast/3: Used for asynchronous casts where you do not need to wait for the results.
    • FLAME.place_child/3: Used to place a child specification to run in a remote instance, serving as a replacement for standard supervision calls like DynamicSupervisor.start_child/2 or Task.Supervisor.start_child/2.
  11. Execute code in a temporary app instance with FLAME.call/3

    main

    Use FLAME.call/3 to execute a block of code in a temporary copy of your entire application. This is ideal for CPU-intensive or resource-heavy tasks.

    Because the code runs in a full copy of your application, you have access to your entire environment, including database connections and all application modules. FLAME automatically handles variables that the function closes over.

    def generate_thumbnails(%Video{} = vid, interval) do
      FLAME.call(MyApp.FFMpegRunner, fn ->
        # I'm runner on a short-lived, temporary server
        tmp_dir = Path.join(System.tmp_dir!(), Ecto.UUID.generate())
        File.mkdir!(tmp_dir)
        System.cmd("ffmpeg", ~w(-i #{vid.url} -vf fps=1/#{interval} #{tmp_dir}/%02d.png))
        urls = VideoStore.put_thumbnails(vid, Path.wildcard(tmp_dir <> "/*.png"))
        Repo.insert_all(Thumbnail, Enum.map(urls, &%{video_id: vid.id, url: &1}))
      end)
    end
  12. Configure FLAME shutdown timeout

    main

    FLAME includes a termination process that allows remote functions time to complete before the node is destroyed. The default shutdown timeout is 30 seconds. You can adjust this in your application configuration (e.g., config/runtime.exs):

    config :flame, :terminator, shutdown_timeout: :timer.seconds(10)