priv/repo/migrations/20260824204740_create_effects.exs

58e6347eeb72 · 5 KB

defmodule OpenAgents.Repo.Migrations.CreateEffects do
  @moduledoc """
  The durable effect outbox (issue #202, EFFECT-001).

  Today an intent commits and its effect fires afterwards, from the same
  process, on the same node: `OpenAgents.Work.start_job/1` inserts the job row
  and then asks Horde for a worker. A crash in the gap between those two lines
  leaves a committed `queued` job that nothing is executing, and nothing
  notices until that node boots again. That is the failure class T3 Code's
  teardown names as "best-effort live reactor" loss of committed work
  (`docs/2026-08-24-coder-first-cloud-complements.md` section 3).

  This table closes the gap. The effect row is inserted in the same transaction
  as the intent it belongs to, so either both exist or neither does. After the
  commit any worker on any node may claim it under a lease, dispatch it, and
  record the outcome. The inline launch stays as a fast path; the outbox is
  what makes it safe for the fast path to fail.

  The column set is borrowed from `deployment_runs`
  (`priv/repo/migrations/20260823070000_create_deployment_control_plane.exs`),
  the only other durable execution record here with a lease: the lease owner
  and its expiry, the attempt counter, the last error, and the
  conditional-update claim those support. What is added is the outbox half —
  the source that asked for
  the effect, the payload digest that fingerprints what was asked for, and the
  deterministic idempotency key that makes a redelivery safe.

  Two digests, because they answer different questions. `payload_digest`
  fingerprints the content, so a reused key carrying different content is
  refused instead of silently answered with the first result. `idempotency_key`
  identifies the effect, so the same intent enqueued twice produces one row and
  one delivery.
  """

  use Ecto.Migration

  def change do
    create table(:effects, primary_key: false) do
      add :id, :binary_id, primary_key: true

      # What to do, and with what. The kind selects a handler; the payload is
      # the handler's whole input, because a handler that reads anything else
      # is not replayable.
      add :kind, :string, null: false
      add :payload, :map, null: false
      add :payload_digest, :string, null: false

      # Who asked. `source_kind` and `source_id` name the committed intent;
      # `source_sequence` is its transcript position where the source has one.
      # A sequence is a position, never an execution claim (EFFECT-002), so it
      # is recorded beside the status rather than used as one.
      add :source_kind, :string, null: false
      add :source_id, :string, null: false
      add :source_sequence, :bigint

      add :idempotency_key, :string, null: false

      add :status, :string, null: false, default: "pending"
      add :attempts, :integer, null: false, default: 0
      add :maximum_attempts, :integer, null: false, default: 5

      # When the effect may next be claimed. Backoff moves this forward; it is
      # never used to express readiness any other way.
      add :available_at, :utc_datetime_usec, null: false

      add :lease_owner, :string
      add :lease_expires_at, :utc_datetime_usec
      add :last_error, :text

      add :claimed_at, :utc_datetime_usec
      add :completed_at, :utc_datetime_usec

      timestamps(type: :utc_datetime_usec)
    end

    # One effect per intent. The unique key is what makes `enqueue/2` safe to
    # call twice — from a retried request, from a replayed reactor, from a
    # caller that does not know whether its last transaction committed.
    create unique_index(:effects, [:idempotency_key])

    # The claim query in one index: pending work, oldest first.
    create index(:effects, [:available_at, :inserted_at],
             where: "status = 'pending'",
             name: :effects_pending_claim_index
           )

    # The reclaim query in one index: leases that have run out.
    create index(:effects, [:lease_expires_at],
             where: "status = 'claimed'",
             name: :effects_expired_lease_index
           )

    # "What happened to the effects this thread/job asked for" without a scan.
    create index(:effects, [:source_kind, :source_id])

    create constraint(:effects, :effects_status_check,
             check: "status IN ('pending', 'claimed', 'done', 'failed')"
           )

    create constraint(:effects, :effects_attempts_nonnegative_check, check: "attempts >= 0")

    create constraint(:effects, :effects_maximum_attempts_positive_check,
             check: "maximum_attempts >= 1"
           )

    create constraint(:effects, :effects_kind_present_check,
             check: "octet_length(kind) BETWEEN 1 AND 80"
           )

    create constraint(:effects, :effects_payload_present_check,
             check: "octet_length(payload::text) >= 2"
           )

    # A lease is a pair or it is nothing: an owner with no expiry never expires,
    # and an expiry with no owner names nobody to reclaim from.
    create constraint(:effects, :effects_lease_pair_check,
             check: """
             (lease_owner IS NULL AND lease_expires_at IS NULL)
             OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
             """
           )

    # A claimed effect holds a lease; a terminal effect holds none and carries
    # the time it reached that state. This is the durable half of the milestone
    # separation: "claimed" and "completed" cannot be the same fact.
    create constraint(:effects, :effects_status_shape_check,
             check: """
             (status = 'pending' AND completed_at IS NULL)
             OR (status = 'claimed' AND lease_owner IS NOT NULL AND claimed_at IS NOT NULL
                 AND completed_at IS NULL)
             OR (status IN ('done', 'failed') AND lease_owner IS NULL
                 AND completed_at IS NOT NULL)
             """
           )
  end
end