An always-on invoice processing pipeline that classifies invoices via an Azure Function, auto-approves small ones, and pauses for human approval on high-value invoices — all orchestrated from SQL inside PostgreSQL.
| pg_durable Feature | How It Appears |
|---|---|
Infinite Loop (@>) |
Pipeline polls for new invoices continuously |
HTTP / Azure Functions (df.http()) |
Calls a deployed Azure Function to classify each invoice |
Human-in-the-Loop (df.wait_for_signal) |
High-value invoices (> $10K) pause until a human approves |
Conditional Branching (df.if) |
Routes invoices through auto-approve or approval-required paths |
Named Results (|=>) |
Passes data between steps (invoice → request body → HTTP response → decision) |
Visualization (df.explain) |
Shows both the static graph and live execution status |
Monitoring (df.list_instances, df.status) |
Observe the pipeline in real time |
Invoices arrive in a PostgreSQL table. A background pipeline picks each one up, sends it to an Azure Function that reads the amount and categorizes it (supplies, consulting, hardware, etc.). Small invoices are approved automatically. Large invoices (over $10,000) are flagged and the pipeline waits for a human to approve or reject them. New invoices can arrive at any time — the loop picks them up on its next pass.
flowchart TD
START((Start)) --> LOOP
subgraph LOOP ["♻️ Infinite Loop"]
POLL["Fetch one pending invoice"]
POLL --> HAS_WORK{Found one?}
HAS_WORK -- No --> WAIT_POLL["Sleep 5s"]
WAIT_POLL --> POLL
HAS_WORK -- Yes --> MARK["Mark 'processing'"]
MARK --> HTTP["☁️ Call Azure Function<br/><i>classify_invoice</i>"]
HTTP --> PARSE["Parse HTTP response"]
PARSE --> OK{Classification<br/>succeeded?}
OK -- No --> FAIL["Mark 'failed'<br/>+ audit log"]
FAIL --> PAUSE
OK -- Yes --> UPDATE["Update vendor,<br/>category, amount"]
UPDATE --> THRESHOLD{Amount<br/>> $10,000?}
THRESHOLD -- No --> AUTO["✅ Auto-approve<br/>+ audit log"]
AUTO --> PAUSE
THRESHOLD -- Yes --> FLAG["Flag 'awaiting_approval'<br/>+ audit log"]
FLAG --> SIGNAL["⏳ Wait for signal<br/><i>'approval' (5 min timeout)</i>"]
SIGNAL --> APPROVED{Approved?}
APPROVED -- Yes --> APPROVE["✅ Mark 'approved'<br/>+ audit log"]
APPROVED -- No / Timeout --> REJECT["❌ Mark 'rejected'<br/>+ audit log"]
APPROVE --> PAUSE
REJECT --> PAUSE
PAUSE["Sleep 2s"] --> POLL
end
Request (sent to Azure Function):
{
"invoice_id": 2,
"description": "GlobalTech Consulting - Cloud infrastructure advisory",
"raw_amount": "$24,500.00"
}Response (from Azure Function):
{
"invoice_id": 2,
"vendor": "GlobalTech Consulting",
"category": "consulting",
"amount": 24500.00,
"currency": "USD",
"requires_approval": true,
"confidence": 0.92
}examples/invoice-approval/
├── README.md ← you are here
├── function-app/
│ ├── host.json
│ ├── requirements.txt
│ └── classify_invoice/
│ ├── __init__.py ← deterministic classifier (no AI dependency)
│ └── function.json
├── scripts/
│ ├── create_function_app.sh
│ ├── deploy_function.sh
│ ├── configure_pg.sh
│ ├── cleanup_azure.sh
│ ├── feed_invoices.sh ← insert random invoices mid-demo
│ ├── smoke_check.sh ← offline syntax/config validation
│ └── live_smoke_check.sh ← deployed Azure Function check
└── sql/
├── 01_schema.sql ← tables + truncate
├── 02_set_vars.sql ← df.setvar for URL/key
├── 03_seed_data.sql ← 2 invoices (1 small, 1 large)
├── 04_explain.sql ← dry-run: preview the graph
├── 05_start_workflow.sql ← launch the pipeline
├── 06_monitor.sql ← check invoice status + audit trail
├── 07_approve.sql ← send approval signal
├── 08_explain_live.sql ← live graph with ✓/⏳ markers
├── 09_verify.sql ← final state summary
└── 10_cancel.sql ← stop the pipeline
- Azure CLI (
az) installed and logged in (az login) - Azure Functions Core Tools (
func) - PostgreSQL with pg_durable enabled
psqlavailable (system or pgrx)
cd examples/invoice-approval
chmod +x scripts/*.sh
./scripts/create_function_app.sh -l eastus./scripts/deploy_function.sh./scripts/live_smoke_check.shpsql -d postgres -p 28817 -f sql/01_schema.sql./scripts/configure_pg.sh -d postgres -p 28817psql -d postgres -p 28817 -f sql/03_seed_data.sql"Invoices come in. Small ones can be auto-approved, but anything over $10K needs a human to sign off. We want this to run continuously inside PostgreSQL — no external job queue, no microservices."
Open sql/05_start_workflow.sql and walk through the structure:
- The infinite loop (
@>) - The Azure Function call (
df.http) - The branching (
df.ifon amount threshold) - The signal wait (
df.wait_for_signal)
psql -d postgres -p 28817 -f sql/04_explain.sqlThis shows the df.explain() dry-run — the tree structure of the pipeline without executing it. Also show the Mermaid diagram above.
psql -d postgres -p 28817 -f sql/05_start_workflow.sqlNote the instance ID returned. The pipeline immediately starts processing the 2 seeded invoices.
psql -d postgres -p 28817 -f sql/06_monitor.sqlYou should see:
- Invoice #1 ($3,420 — office supplies): auto-approved ✅
- Invoice #2 ($24,500 — consulting): awaiting_approval ⏳
SELECT df.explain('<instance-id>');The signal wait node shows ⏳.
SELECT df.signal('<instance-id>', 'approval', '{"approved": true, "approver": "demo-user"}');psql -d postgres -p 28817 -f sql/06_monitor.sqlInvoice #2 is now approved, audit trail shows the approver.
In another terminal:
./scripts/feed_invoices.sh -d postgres -p 28817 -n 3Wait a few seconds, then monitor again — the loop picks them up automatically.
psql -d postgres -p 28817 -f sql/06_monitor.sqlNew invoices are being processed. Any over $10K will pause for signals.
psql -d postgres -p 28817 -f sql/09_verify.sql"This entire pipeline — HTTP calls, human approval gates, infinite loops, conditional logic — runs inside PostgreSQL. No external orchestrator. Survives crashes. All visible through SQL."
Optionally cancel the pipeline:
SELECT df.cancel('<instance-id>', 'Demo complete');./scripts/cleanup_azure.sh -y- The classifier Azure Function is deterministic (keyword-based, no AI). It always returns consistent results for the same input.
- The $10,000 threshold is hardcoded in the SQL workflow — change it in
05_start_workflow.sqlto adjust. - The approval signal has a 5-minute timeout. If no signal is sent, the invoice is automatically rejected.
feed_invoices.sh -s 10runs continuously, inserting a batch every 10 seconds.