Research sample · PostgreSQL 17

PostgreSQL durable queue claiming

Research question

Audit this PostgreSQL queue implementation for safe concurrent claiming, idempotency, lease recovery, bounded retries, and current PostgreSQL practices. Verify transaction and locking claims precisely.

Topics covered: PostgreSQL job queue, SKIP LOCKED, idempotent workers

Default research

PostgreSQL 17 · 9,993 characters

Research complete

Question

Audit this PostgreSQL queue implementation for safe concurrent claiming, idempotency, lease recovery, bounded retries, and current PostgreSQL practices. Verify transaction and locking claims precisely.

Submitted code · durable-queue.sqlPostgreSQL
create schema if not exists app;

create type app.job_state as enum (
  'pending',
  'running',
  'retry_wait',
  'succeeded',
  'dead'
);

create table app.jobs (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  operation_key text not null,
  kind text not null,
  payload jsonb not null check (jsonb_typeof(payload) = 'object'),
  state app.job_state not null default 'pending',
  attempts integer not null default 0 check (attempts >= 0),
  max_attempts integer not null default 6 check (max_attempts between 1 and 20),
  available_at timestamptz not null default clock_timestamp(),
  lease_owner text,
  lease_token uuid,
  lease_expires_at timestamptz,
  last_error jsonb,
  result jsonb,
  created_at timestamptz not null default clock_timestamp(),
  updated_at timestamptz not null default clock_timestamp(),
  completed_at timestamptz,
  constraint jobs_operation_key_unique unique (tenant_id, operation_key),
  constraint jobs_lease_shape check (
    (state = 'running' and lease_owner is not null and lease_token is not null and lease_expires_at is not null)
    or
    (state <> 'running' and lease_owner is null and lease_token is null and lease_expires_at is null)
  )
View all 341 lines
create schema if not exists app;

create type app.job_state as enum (
  'pending',
  'running',
  'retry_wait',
  'succeeded',
  'dead'
);

create table app.jobs (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  operation_key text not null,
  kind text not null,
  payload jsonb not null check (jsonb_typeof(payload) = 'object'),
  state app.job_state not null default 'pending',
  attempts integer not null default 0 check (attempts >= 0),
  max_attempts integer not null default 6 check (max_attempts between 1 and 20),
  available_at timestamptz not null default clock_timestamp(),
  lease_owner text,
  lease_token uuid,
  lease_expires_at timestamptz,
  last_error jsonb,
  result jsonb,
  created_at timestamptz not null default clock_timestamp(),
  updated_at timestamptz not null default clock_timestamp(),
  completed_at timestamptz,
  constraint jobs_operation_key_unique unique (tenant_id, operation_key),
  constraint jobs_lease_shape check (
    (state = 'running' and lease_owner is not null and lease_token is not null and lease_expires_at is not null)
    or
    (state <> 'running' and lease_owner is null and lease_token is null and lease_expires_at is null)
  )
);

create index jobs_claimable_idx
on app.jobs (tenant_id, available_at, created_at, id)
where state in ('pending', 'retry_wait');

create index jobs_expired_lease_idx
on app.jobs (tenant_id, lease_expires_at, id)
where state = 'running';

create table app.job_events (
  sequence bigint generated always as identity primary key,
  id uuid not null default gen_random_uuid(),
  tenant_id uuid not null,
  job_id uuid not null references app.jobs(id) on delete cascade,
  event_type text not null check (event_type in (
    'created', 'claimed', 'retry_scheduled',
    'succeeded', 'dead', 'lease_expired'
  )),
  actor text not null,
  details jsonb not null default '{}'::jsonb check (jsonb_typeof(details) = 'object'),
  created_at timestamptz not null default clock_timestamp(),
  constraint job_events_id_unique unique (tenant_id, id)
);

create index job_events_job_timeline_idx
on app.job_events (tenant_id, job_id, sequence);

alter table app.jobs enable row level security;
alter table app.jobs force row level security;
alter table app.job_events enable row level security;
alter table app.job_events force row level security;

create or replace function app.current_tenant_id()
returns uuid
language sql
stable
set search_path = ''
as $$
  select nullif(current_setting('app.tenant_id', true), '')::uuid
$$;

create policy jobs_tenant_select on app.jobs
for select
using (tenant_id = app.current_tenant_id());

create policy job_events_tenant_select on app.job_events
for select
using (tenant_id = app.current_tenant_id());

revoke all on schema app from public;
revoke all on all tables in schema app from public;
revoke all on all functions in schema app from public;

create or replace function app.claim_jobs(
  p_tenant_id uuid,
  p_owner text,
  p_limit integer default 10,
  p_lease interval default interval '30 seconds'
)
returns setof app.jobs
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_now timestamptz := clock_timestamp();
  v_job app.jobs;
begin
  if p_tenant_id is null or nullif(btrim(p_owner), '') is null then
    raise exception using errcode = '22023', message = 'Tenant/owner required.';
  end if;
  if p_limit not between 1 and 100 or p_lease <= interval '0 seconds' then
    raise exception using errcode = '22023', message = 'Invalid claim bounds.';
  end if;

  for v_job in
    with candidates as materialized (
      select id
      from app.jobs
      where tenant_id = p_tenant_id
        and state in ('pending', 'retry_wait')
        and available_at <= v_now
      order by available_at, created_at, id
      for update skip locked
      limit p_limit
    ), claimed as (
      update app.jobs as jobs
      set state = 'running',
          attempts = jobs.attempts + 1,
          lease_owner = p_owner,
          lease_token = gen_random_uuid(),
          lease_expires_at = v_now + p_lease,
          updated_at = v_now
      from candidates
      where jobs.id = candidates.id
        and jobs.tenant_id = p_tenant_id
      returning jobs.*
    )
    select * from claimed
  loop
    insert into app.job_events (
      tenant_id, job_id, event_type, actor, details, created_at
    ) values (
      v_job.tenant_id,
      v_job.id,
      'claimed',
      p_owner,
      jsonb_build_object(
        'attempt', v_job.attempts,
        'leaseToken', v_job.lease_token,
        'leaseExpiresAt', v_job.lease_expires_at
      ),
      v_now
    );
    return next v_job;
  end loop;
end;
$$;

create or replace function app.renew_job_lease(
  p_tenant_id uuid,
  p_job_id uuid,
  p_owner text,
  p_token uuid,
  p_lease interval default interval '30 seconds'
)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_now timestamptz := clock_timestamp();
  v_renewed boolean := false;
begin
  update app.jobs
  set lease_expires_at = v_now + p_lease,
      updated_at = v_now
  where tenant_id = p_tenant_id
    and id = p_job_id
    and state = 'running'
    and lease_owner = p_owner
    and lease_token = p_token
    and lease_expires_at > v_now
  returning true into v_renewed;

  return coalesce(v_renewed, false);
end;
$$;

create or replace function app.complete_job(
  p_tenant_id uuid,
  p_job_id uuid,
  p_owner text,
  p_token uuid,
  p_result jsonb
)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_now timestamptz := clock_timestamp();
  v_completed boolean := false;
begin
  update app.jobs
  set state = 'succeeded',
      result = p_result,
      lease_owner = null,
      lease_token = null,
      lease_expires_at = null,
      completed_at = v_now,
      updated_at = v_now
  where tenant_id = p_tenant_id
    and id = p_job_id
    and state = 'running'
    and lease_owner = p_owner
    and lease_token = p_token
    and lease_expires_at > v_now
  returning true into v_completed;

  if coalesce(v_completed, false) then
    insert into app.job_events (
      tenant_id, job_id, event_type, actor, details, created_at
    ) values (
      p_tenant_id, p_job_id, 'succeeded', p_owner,
      jsonb_build_object('leaseToken', p_token), v_now
    );
  end if;
  return coalesce(v_completed, false);
end;
$$;

create or replace function app.fail_job(
  p_tenant_id uuid,
  p_job_id uuid,
  p_owner text,
  p_token uuid,
  p_error jsonb,
  p_retry_delay interval default interval '5 seconds'
)
returns app.job_state
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_now timestamptz := clock_timestamp();
  v_state app.job_state;
begin
  update app.jobs
  set state = case when attempts >= max_attempts then 'dead'::app.job_state else 'retry_wait'::app.job_state end,
      available_at = case when attempts >= max_attempts then available_at else v_now + p_retry_delay end,
      last_error = p_error,
      lease_owner = null,
      lease_token = null,
      lease_expires_at = null,
      completed_at = case when attempts >= max_attempts then v_now else null end,
      updated_at = v_now
  where tenant_id = p_tenant_id
    and id = p_job_id
    and state = 'running'
    and lease_owner = p_owner
    and lease_token = p_token
    and lease_expires_at > v_now
  returning state into v_state;

  if v_state is not null then
    insert into app.job_events (
      tenant_id, job_id, event_type, actor, details, created_at
    ) values (
      p_tenant_id,
      p_job_id,
      case when v_state = 'dead' then 'dead' else 'retry_scheduled' end,
      p_owner,
      jsonb_build_object('leaseToken', p_token, 'error', p_error),
      v_now
    );
  end if;
  return v_state;
end;
$$;

create or replace function app.release_expired_jobs(
  p_tenant_id uuid,
  p_limit integer default 100
)
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_now timestamptz := clock_timestamp();
  v_job record;
  v_count integer := 0;
begin
  for v_job in
    with expired as materialized (
      select id
      from app.jobs
      where tenant_id = p_tenant_id
        and state = 'running'
        and lease_expires_at <= v_now
      order by lease_expires_at, id
      for update skip locked
      limit p_limit
    )
    update app.jobs as jobs
    set state = case when jobs.attempts >= jobs.max_attempts then 'dead'::app.job_state else 'retry_wait'::app.job_state end,
        available_at = case when jobs.attempts >= jobs.max_attempts then jobs.available_at else v_now end,
        last_error = jsonb_build_object('code', 'LEASE_EXPIRED', 'retryable', jobs.attempts < jobs.max_attempts),
        lease_owner = null,
        lease_token = null,
        lease_expires_at = null,
        completed_at = case when jobs.attempts >= jobs.max_attempts then v_now else null end,
        updated_at = v_now
    from expired
    where jobs.id = expired.id
      and jobs.tenant_id = p_tenant_id
    returning jobs.id, jobs.state
  loop
    v_count := v_count + 1;
    insert into app.job_events (
      tenant_id, job_id, event_type, actor, details, created_at
    ) values (
      p_tenant_id,
      v_job.id,
      case when v_job.state = 'dead' then 'dead' else 'lease_expired' end,
      'watchdog',
      jsonb_build_object('recoveredState', v_job.state),
      v_now
    );
  end loop;
  return v_count;
end;
$$;

grant usage on schema app to authenticated;
grant select on app.jobs, app.job_events to authenticated;
grant execute on function app.claim_jobs(uuid, text, integer, interval) to service_role;
grant execute on function app.renew_job_lease(uuid, uuid, text, uuid, interval) to service_role;
grant execute on function app.complete_job(uuid, uuid, text, uuid, jsonb) to service_role;
grant execute on function app.fail_job(uuid, uuid, text, uuid, jsonb, interval) to service_role;
grant execute on function app.release_expired_jobs(uuid, integer) to service_role;

Result

PostgreSQL3 priority findings5 displayed sources

The queue claim is sound; the tenant boundary is not

FOR UPDATE SKIP LOCKED is used correctly for concurrent queue consumption, and lease tokens reject stale completion. The privileged functions still trust a caller-supplied tenant identifier, creating the highest-impact defect in the sample.

Priority findings

03
  1. 01

    Do not trust tenant IDs passed to privileged functions

    Fix now

    Each SECURITY DEFINER function filters by the supplied tenant without comparing it with trusted session context. RLS does not automatically repair that authorization boundary.

    durable-queue.sql:115

    where tenant_id = p_tenant_id
    WEB-3 · WEB-4
  2. 02

    Retain the atomic SKIP LOCKED claim

    Keep

    Candidate rows are locked and updated within one statement and transaction. The pattern is appropriate for queue consumers, with fairness traded for throughput.

    durable-queue.sql:119

    for update skip locked
    WEB-1 · WEB-2
  3. 03

    Document at-least-once delivery

    Decide

    Lease fencing rejects stale completion, but an external side effect can succeed before the completion transaction commits. Handlers still need durable idempotency.

    durable-queue.sql:179

    and lease_expires_at > v_now
    WEB-5

Verify tenant context at the function boundary

Reject a tenant argument that differs from trusted session context before changing any row.

Modernized techniquePostgreSQL
if p_tenant_id is null
   or p_tenant_id <> app.current_tenant_id() then
  raise exception 'unauthorized'
    using errcode = '42501';
end if;

Authoritative sources

What this PostgreSQL durable queue audit covers

This PostgreSQL 17 example implements a tenant-scoped durable job queue with FOR UPDATE SKIP LOCKED claiming, lease tokens, retry limits, dead-letter transitions, idempotency keys, event history, and row-level security. The audit tests both concurrency correctness and the authorization boundary around privileged functions.

Hattrick verifies the SQL with a PostgreSQL grammar parser, researches exact version 17 documentation, and separates database guarantees from application-level delivery claims. That distinction is essential when a design safely claims rows but still cannot promise exactly-once external side effects.

Engineering questions answered

  • Is FOR UPDATE SKIP LOCKED used atomically and safely for concurrent workers?
  • Can SECURITY DEFINER functions bypass the intended tenant boundary?
  • What do lease fencing and idempotency guarantee after a worker crash?

How Hattrick approaches the question

  1. 01

    Transaction-level verification

    Checks row locking, state transitions, and event insertion against PostgreSQL 17 transaction semantics.

  2. 02

    Security-boundary analysis

    Examines how RLS, function ownership, search_path, grants, and caller-supplied tenant IDs interact.

  3. 03

    Guarantee-aware conclusions

    Distinguishes safe concurrent claiming from at-least-once delivery and exactly-once side-effect claims.

Practical takeaway

Concurrency correctness does not imply tenant safety

The SKIP LOCKED claim can be correct while the privileged API remains unsafe. Durable queue reviews must evaluate locking, idempotency, leases, role privileges, and tenant derivation as separate guarantees.