PgFlow: Elixir vs Supabase (TypeScript/Deno)

Copy Markdown View Source

Both implementations share the same base PostgreSQL schema and SQL orchestration layer. They can run side-by-side against the same database.

Architecture

LayerTypeScript/DenoElixir
DSLFluent builder with full generic type inferenceMacros (use PgFlow.Flow, use PgFlow.Job)
SQL Core30+ PL/pgSQL functionsSame SQL, shared via ecto_evolver migrations
WorkerStateless Deno edge functions (HTTP-triggered)Long-running OTP GenServer per flow/job
Pollingpgflow.read_with_poll() (blocking)pgmq.read() (non-blocking) + adaptive backoff
Signalpgmq polling onlyPolling (default) or LISTEN/NOTIFY
ClientTS SDK with Supabase Realtime subscriptionsPgFlow.Client with sync polling
CLIpgflow compile via control plane HTTPmix pgflow.gen.flow_migration generates migrations directly
DashboardNone (relies on Supabase dashboard)Built-in Phoenix LiveView dashboard
RealtimeSupabase Realtime broadcast via realtime.send()Telemetry events + structured logging
Recoverypgmq visibility timeout + worker restartExplicit StalledTaskRecovery GenServer

Flow Definition

TypeScript — fluent builder with method chaining:

const flow = new Flow<{ orderId: number }>({ slug: 'process_order' })
  .step({ slug: 'validate' }, (input) => ({ valid: true }))
  .step({ slug: 'charge', dependsOn: ['validate'] }, (deps) => ({ charged: true }))
  .map({ slug: 'notify' }, (item) => ({ sent: true }))

Elixir — macro DSL:

defmodule ProcessOrder do
  use PgFlow.Flow
  @flow queue: :process_order, max_attempts: 3

  step :validate do
    fn input, _ctx -> %{valid: true} end
  end

  step :charge, depends_on: [:validate] do
    fn deps, _ctx -> %{charged: true} end
  end

  map :notify do
    fn item, _ctx -> %{sent: true} end
  end
end

Both compile to identical SQL: pgflow.create_flow() + pgflow.add_step().

Step Types

TypeTypeScriptElixir
Single step.step()step/2, step/3.
Array producer.array()step/2 returning list.
Parallel map.map()map/2, map/3
Dependent map.map({ array: })map :name, array: :step

Step/Flow Options

OptionTypeScriptElixirDefault
Identifierslugqueue: / slug:
Max retriesmaxAttemptsmax_attempts:1
Retry delaybaseDelaybase_delay:1s
Timeouttimeouttimeout:30s (TS), 60s (Elixir)
Start delaystartDelaystart_delay:0

Handler Context

FieldTypeScriptElixir
Run IDcontext.stepTask.run_idctx.run_id
Step slugcontext.stepTask.step_slugctx.step_slug
Task indexcontext.stepTask.task_indexctx.task_index
Attemptcontext.stepTask.attemptctx.attempt
Flow inputawait context.flowInputContext.get_flow_input(ctx)
DB accesscontext.sql (postgres.js)ctx.repo (Ecto)
Shutdown signalcontext.shutdownSignalOTP supervision
Environmentcontext.envApplication.get_env/3

Both lazy-load flow input to avoid unnecessary DB queries for non-root steps.

Worker Model

TypeScript: Stateless Edge Functions

Supabase Edge Function (triggered per batch)
   register worker  heartbeat  processBatch  exit
   Horizontal scaling via multiple function instances
   Worker deprecation via is_deprecated flag
   ensure_workers() pings functions via pg_cron

Elixir: Long-Running OTP GenServers

PgFlow.Supervisor (rest_for_one)
 Task.Supervisor
 Signal.Notify (when signal_strategy: :notify)
 WorkerSupervisor (dynamic)
    Worker.Server (one per flow/job)
 StalledTaskRecovery
 :flow_starter (one-shot registration)
AspectTypeScriptElixir
LifecycleEphemeral per invocationPersistent GenServer
StateStatelessIn-memory (active_tasks map)
ScalingMultiple edge function instancesOne worker per flow/job
Crash recoveryNew function invocationOTP supervisor restart
Stale task recoverypgmq visibility timeoutExplicit sweep every 15s
Worker heartbeatlast_heartbeat_at in DBDB registration + OTP monitor
Worker deprecationdeprecated_at flagNot needed (OTP restarts)
Graceful shutdownshutdownSignalLifecycle state machine

Polling Protocol

Both use the same two-phase protocol:

  1. Reserve messages from pgmq (makes them invisible)
  2. pgflow.start_tasks() — atomically claim tasks, return details
  3. Execute handler
  4. pgflow.complete_task() or pgflow.fail_task()

Difference in phase 1:

AspectTypeScriptElixir
Read callpgflow.read_with_poll() (blocking)pgmq.read() (non-blocking)
Idle behaviorBlocks in DB up to maxPollSecondsAdaptive jittered backoff (1s-5s)
Wake-uppgmq polling onlyPolling or LISTEN/NOTIFY

TypeScript uses blocking read_with_poll because stateless edge functions have no event loop to schedule polls — the database must do the waiting. Elixir workers are long-running GenServers where Process.send_after provides native scheduling, so pgmq.read() returns instantly and the connection goes back to the Ecto pool. This avoids holding DB connections idle and enables adaptive backoff with cancellable timers (NOTIFY or completion events can wake the worker immediately).

Note that read_with_poll is not truly push-based — PostgreSQL runs an internal poll loop at pollIntervalMs (default 200ms) inside the blocking call:

Deno worker                    PostgreSQL
   |                              |
   |---- read_with_poll() ------->|
   |     (connection held)        |-- SELECT FROM q_* ... nothing
   |                              |-- wait 200ms
   |                              |-- SELECT FROM q_* ... nothing
   |                              |-- wait 200ms
   |                              |-- SELECT FROM q_* ... MESSAGE!
   |<--- [message] ---------------|

The real question is where the poll loop lives and what tradeoffs that creates:

Deno (pgmq)Elixir (polling)Elixir (notify)
Poll loop locationInside PostgreSQLGenServer processN/A (event-driven)
Busy latency~200ms (fixed)~50ms (faster)Near-zero
Idle latency~200ms (fixed)Up to 5s (slower)Near-zero + 30s fallback
DB connection during idleHeld for up to 5sNot heldNot held
Truly push-based?NoNoYes

Feature Comparison

FeatureTypeScriptElixirNotes
Single stepsYesYesIdentical semantics
Map stepsYesYesIdentical
Dependent map stepsYesYesIdentical
Empty array cascadeYesYesSame SQL logic
Exponential backoff retryYesYesSame SQL function
Compile-time DAG validationYesYesTS generics vs Kahn's algorithm
Two-phase pollingYesYesSame protocol
start_delay optionYesYesPer-step delay before queueing
Flow input lazy loadingYesYescontext.flowInput / Context.get_flow_input
Type safetyYesNoTS generics flow through entire DAG
Client SDK (browser)YesNoSupabase Realtime subscriptions
Realtime events (WebSocket)YesNoElixir uses telemetry instead
Worker deprecationYesNoOTP supervisors handle restarts
LISTEN/NOTIFY signalNoYespgmq 1.8.0+ with enable_notify_insert
Stalled task recoveryNoYesStalledTaskRecovery GenServer
Jobs DSLNoYesuse PgFlow.Job with perform block
Cron schedulingNoYescron: [schedule: "@hourly"] via pg_cron
DashboardNoYesLiveView: flows, jobs, crons, runs, workers
Sync flow executionVia clientYesPgFlow.Client.start_flow_sync/3
Telemetry eventsNoYes12 events across flow/step/task/worker
Structured loggingNoYesFancy (dev) and simple (prod) formats
Mix tasksN/AYesgen.flow, setup, stamp, check_schema, etc.

Shared SQL Core

Both call the same PostgreSQL functions:

FunctionPurpose
pgflow.create_flowRegister flow + create pgmq queue
pgflow.add_stepAdd step with dependencies
pgflow.start_flowCreate run, enqueue root steps
pgflow.start_tasksClaim tasks, return details
pgflow.complete_taskSave output, cascade to dependents
pgflow.fail_taskRetry with backoff or fail permanently
pgflow.start_ready_stepsActivate steps with all deps satisfied
pgflow.cascade_complete_taskless_stepsHandle empty array propagation
pgflow.maybe_complete_runComplete run when all steps done

Database schema (7 tables): flows, steps, deps (definition) + runs, step_states, step_tasks, workers (runtime).

Schema Divergences

The Elixir implementation adds the following extensions that are not present in the upstream TypeScript/Deno project:

ChangeTableDescription
flow_type columnpgflow.flowsDistinguishes background jobs from multi-step DAG workflows in the dashboard.
Extension SQL functionspgflowregister_worker, mark_worker_stopped, recover_stalled_tasks, flow_exists, get_flow_input, get_step_output — installed via mix pgflow.gen.helpers_migration.

These additions are backward-compatible: existing flow records default to flow_type = 'flow', and extension functions don't modify core pgflow tables. TypeScript workers can safely ignore them.

Compilation

TypeScript: CLI sends HTTP request to ControlPlane edge function, which extracts the flow shape and generates SQL. Written to supabase/migrations/.

Elixir: mix pgflow.gen.flow_migration MyFlow reads __pgflow_definition__/0 at compile time and generates an Ecto migration with the same SQL calls. Also compiles flows at worker startup via FlowCompiler.