mech.app
Dev Tools

Absurd: How Postgres-Native Durable Workflows Turn Your Database into an Orchestrator

Using Postgres as the state machine and queue for durable workflow execution. No external orchestrator, no separate queue service, just SQL transactions.

Source: earendil-works.github.io
Absurd: How Postgres-Native Durable Workflows Turn Your Database into an Orchestrator

Absurd is a durable workflow system that runs entirely inside Postgres. No external orchestrator. No message broker. No coordination service. Just a single SQL schema file, your existing database, and lightweight SDKs that dispatch tasks into tables.

The design inverts the typical workflow architecture. Instead of an external engine managing state and calling your database for persistence, Absurd uses stored procedures to handle task execution, checkpointing, and retry logic. Workers pull tasks from Postgres queues using advisory locks. State lives in rows. Events are cached in tables. Everything happens inside transactions.

The Lobsters discussion around Absurd reflects growing developer interest in simpler alternatives to Temporal and Airflow. Teams that already run Postgres are looking for ways to add durable execution without deploying separate orchestration infrastructure. The Postgres-native approach eliminates deployment complexity while leveraging ACID guarantees engineers already trust. You get transactional consistency between workflow state and application data without coordinating across services.

How It Works

Absurd models workflows as tasks subdivided into steps. Each step acts as a checkpoint. When a step completes, its return value is persisted. If the task fails, it resumes from the last successful step. Tasks can also sleep (suspend until a timestamp) or await events (suspend until a named event is emitted).

The core primitives:

  • Tasks: Long-running workflows that may span minutes, days, or years
  • Steps: Atomic units of work that checkpoint state
  • Events: Named signals that wake suspended tasks (first emit wins, cached in tables)
  • Queues: Postgres tables where workers claim tasks using advisory locks

Workers poll queues, claim tasks, execute steps, and commit results in transactions. If a worker crashes, the advisory lock releases and another worker can claim the task. If Postgres fails over, tasks resume from their last checkpoint once the new primary is available.

Architecture

Absurd uses a single absurd.sql schema file that creates tables, triggers, and stored procedures. The schema handles:

  • Task and step state storage
  • Queue management with priority and concurrency controls
  • Event caching and delivery
  • Retry logic with exponential backoff
  • Cleanup and retention policies

SDKs (TypeScript, Python, Go) are thin clients. They serialize task parameters, insert rows, and deserialize results. The heavy lifting happens in Postgres.

TypeScript Example:

import { Absurd } from 'absurd-sdk';

const app = new Absurd();

app.registerTask({ name: 'order-fulfillment' }, async (params, ctx) => {
  // Step 1: Process payment (checkpointed)
  const payment = await ctx.step('process-payment', async () => {
    return { paymentId: `pay-${params.orderId}`, amount: params.amount };
  });

  // Step 2: Wait for external event (task suspends)
  const shipment = await ctx.awaitEvent(`shipment.packed:${params.orderId}`);

  // Step 3: Send notification (checkpointed)
  await ctx.step('send-notification', async () => {
    return { sentTo: params.email, trackingNumber: shipment.trackingNumber };
  });

  return { orderId: params.orderId, payment, trackingNumber: shipment.trackingNumber };
});

await app.startWorker();

Python Example:

from absurd_sdk import Absurd

app = Absurd()

@app.register_task(name="order-fulfillment")
def process_order(params, ctx):
    def process_payment():
        return {
            "payment_id": f"pay-{params['order_id']}",
            "amount": params["amount"]
        }
    
    payment = ctx.step("process-payment", process_payment)
    shipment = ctx.await_event(f"shipment.packed:{params['order_id']}")
    
    def send_notification():
        return {
            "sent_to": params["email"],
            "tracking_number": shipment["tracking_number"]
        }
    
    ctx.step("send-notification", send_notification)
    
    return {
        "order_id": params["order_id"],
        "payment": payment,
        "tracking_number": shipment["tracking_number"]
    }

app.start_worker()

When ctx.step() runs, the SDK calls a stored procedure that checks if the step already completed. If yes, it returns the cached result. If no, it executes the function and persists the output. When ctx.awaitEvent() runs, the task suspends and releases its worker. When the event is emitted (via another task or external trigger), Postgres wakes the task and it resumes.

State Management and Coordination

Absurd uses Postgres advisory locks to coordinate workers without polling. When a worker claims a task, it acquires an advisory lock on the task ID. Other workers skip that task. If the worker crashes, the lock releases automatically when the connection closes.

For event delivery, Absurd uses LISTEN/NOTIFY. When an event is emitted, a trigger fires a notification. Workers listening on that channel wake up and check for tasks awaiting that event. The event itself is stored in a table, so even if no worker is listening at emit time, the task will resume when a worker next polls.

This design eliminates external coordination but ties availability to Postgres. If your database is down, workflows stop. If you have a read replica setup, you need to ensure workers connect to the primary for writes.

Idempotency Requirements

Step functions must be idempotent. This is not optional. When a worker crashes after a step completes but before the transaction commits, another worker will re-execute that step. If the step has side effects (like charging a credit card or sending an email), you need to handle deduplication.

Strategies for idempotency:

  • Idempotency keys: Pass a unique key (like ${taskId}-${stepName}) to external APIs that support idempotent operations
  • Database constraints: Use unique constraints on tables to prevent duplicate inserts (e.g., UNIQUE(order_id, step_name))
  • Check-then-act: Query state before performing the action (e.g., check if payment already exists before charging)

The Postgres transaction boundary protects internal state, but external side effects require explicit deduplication logic. If your step function calls a third-party API, that API call may execute twice.

Failure Modes

Understanding how Absurd behaves under failure is critical for production deployments. The table below covers the most common scenarios and their recovery paths.

Failure ScenarioBehaviorRecovery
Worker crash during step executionAdvisory lock releases, task becomes claimableAnother worker picks up task, re-executes current step (idempotency required)
Postgres primary failoverAll in-flight tasks lose connectionWorkers reconnect to new primary, resume from last checkpoint
Network partition between worker and PostgresWorker cannot commit, transaction rolls backTask remains in queue, another worker claims it
Step function throws exceptionTask retries with exponential backoffContinues until max retries, then moves to dead letter queue
Event emitted before task awaits itEvent cached in tableTask resumes immediately when it reaches awaitEvent()

The key risk is re-execution of steps with side effects. If a worker crashes between step completion and transaction commit, the step will run again. Use idempotency keys, database constraints, or check-then-act patterns to prevent duplicate charges, emails, or API calls.

Observability

All state lives in Postgres tables. You can query task status, step history, and event logs with SQL. The schema includes:

  • absurd.tasks: Current state of all tasks (pending, running, completed, failed)
  • absurd.steps: History of step executions with timestamps and outputs
  • absurd.events: Cached events with emit timestamps
  • absurd.queues: Queue configuration and concurrency limits

For monitoring, you can:

  • Count tasks by state to track throughput
  • Measure step duration to identify slow operations
  • Query failed tasks to debug errors
  • Join tasks and events to trace workflow dependencies

There is no built-in UI. You bring your own dashboard or use absurdctl (a CLI tool in the repo) to inspect state.

Deployment Shape

Absurd requires:

  1. A Postgres database (version 12 or later)
  2. The absurd.sql schema loaded into your database
  3. Workers running your task code (can be serverless functions, long-running processes, or Kubernetes pods)

Workers can scale horizontally. Each worker polls the queue and claims tasks using advisory locks. You control concurrency per queue via configuration in the schema.

For high availability, you need Postgres replication. Workers must connect to the primary for writes. If you use a managed Postgres service (RDS, Cloud SQL, etc.), failover is automatic but tasks will pause during the failover window (typically 30-60 seconds).

For multi-region deployments, you need to decide if tasks can run in any region or if they are region-specific. Absurd does not handle cross-region replication. You would need to run separate Postgres instances per region or use logical replication to sync task state.

Security Boundaries

Absurd runs inside your database, so security is Postgres security. Workers need:

  • SELECT, INSERT, UPDATE, DELETE on task and event tables
  • EXECUTE on stored procedures
  • LISTEN on notification channels

You can use Postgres roles to isolate workers. For example, a worker that only processes payments can have access to the payment-queue table but not the admin-queue table.

For secrets (API keys, credentials), you need to handle encryption yourself. Absurd stores task parameters as JSONB. If those parameters include secrets, encrypt them before dispatch and decrypt them in the step function.

When to Use Absurd

Absurd fits when:

  • You already run Postgres and want to avoid adding orchestration infrastructure
  • Your workflows need durable execution with checkpointing and retries
  • You can tolerate Postgres downtime stopping workflows
  • Your team is comfortable debugging workflows with SQL queries
  • You need transactional guarantees between workflow state and application data

Absurd does not fit when:

  • You need sub-second task latency (polling and advisory locks add overhead)
  • Your workflows require complex DAG scheduling or conditional branching (Absurd is linear with events)
  • You need multi-region active-active orchestration (Postgres replication is not designed for this)
  • You want a managed service with a UI and built-in observability (Absurd is self-hosted and SQL-based)

Technical Verdict

Absurd is a pragmatic choice for teams that want durable workflows without operational complexity. By using Postgres as the orchestrator, you eliminate external dependencies and leverage ACID guarantees you already trust. The trade-off is that you inherit Postgres availability and scaling limits.

Use Absurd when your workflows are I/O-bound, your task volume fits in a single Postgres instance, and your team prefers SQL-based observability over a managed UI. Avoid it when you need high-throughput task processing, complex DAG scheduling, or multi-region orchestration.

The design is opinionated: workflows are linear, events are cached, and state is rows. If those constraints fit your use case, Absurd removes a lot of infrastructure. Just remember that step functions must be idempotent, because Postgres transactions protect internal state but not external side effects.