Skip to content

fetch_orchestration_item: avoid the advisory-lock convoy and bound the candidate loop #34

Open

Description

@waldemort-auto

Follow-up to #21. The fix for #21 computes the lock lease when the instance is claimed, so a slow fetch can no longer return an already-expired lease. The behaviour below is still correct, but it costs throughput and latency under contention. Each item can be done separately.

1. Advisory-lock convoy on a hot instance

Every dispatcher selects the same oldest candidate (ORDER BY visible_at, id LIMIT 1) and then blocks on pg_advisory_xact_lock(hashtext(instance_id)). Under contention the dispatchers wait for each other's fetch transactions on that one instance, while other instances have work.

Proposal: claim with pg_try_advisory_xact_lock. Put a held instance on an in-call skip list (instance_id <> ALL(skip_list) in the candidate query) and try the next candidate.

Advisory locks are database-wide. With a non-blocking claim, an unscoped key makes a fetch skip an instance only because another schema in the same database is working on the same instance id. So the key must become hashtext('<schema>.' || instance_id). Only this procedure takes that lock.

2. Spin on a row-locked candidate

When the FOR UPDATE ... SKIP LOCKED re-check finds no row, for example while an ack is still deleting the rows, the loop selects the same candidate again until that transaction ends.

Proposal: add the candidate to the skip list. Use a separate variable for the re-check. Writing the re-check result into the candidate variable stores NULL in the skip list, and <> ALL(...) with a NULL element rejects every remaining candidate. #22 has this problem.

3. Unbounded candidate loop

One call can try any number of candidates.

Proposal: stop after a fixed number of candidates (for example 50) and return no row. The dispatcher already treats no row as no work and polls again.

4. Stale caller clock in predicates

Visibility (visible_at <= p_now_ms) and lock expiry (locked_until > p_now_ms) use p_now_ms. The caller reads it before the call. Under long waits it lags real time. This is conservative: work becomes eligible later, never early.

Proposal: use p_now_ms plus the time spent in the statement, refreshed on each loop iteration. This keeps the caller's clock domain, which wrote visible_at and locked_until.

Prior art and tests

#22 (migration 0024) implements 1-3 except the NULL fix in 2.

An implementation of all four exists and is tested. On PostgreSQL 15 its regression tests showed:

Case Current main With the change
Contended instance fetch blocks about 4 s next instance returned immediately
Row-locked candidate fetch spins about 4 s next instance returned
Same instance id in another schema fetch blocks about 4 s not contended
60 held candidates fetch blocks about 4 s returns no row immediately

It can be opened as a PR when this is picked up.

Related: microsoft/duroxide#46, microsoft/duroxide#47.

Activity

  1. waldemort-auto commented on Oct 26, 2026

    @waldemort-auto
    ContributorAuthor

    More data for item 1, from the test pass for #44.

    Setup: pg-stress, 30 s per run, 8 orchestration and 8 worker dispatchers, 20 concurrent instances, PostgreSQL 15.19.

    Result: 98 deadlocks in the PostgreSQL log, on main and on #44 combined. Every one is between two fetch_orchestration_item calls waiting on advisory locks.

    Cause: the candidate loop keeps the advisory lock of each candidate it gives up on (CONTINUE at L139 and L164 of 0025). Two fetches that each hold one instance's lock and want the other's then deadlock. PostgreSQL breaks the cycle after deadlock_timeout (1 s) with 40P01.

    Deadlocks per 30 s run
    main (3beba92) 13, 19, 12, 27, 23
    #44 3, 1, 0, 0, 0

    The pg_try_advisory_xact_lock claim proposed in item 1 also removes these deadlocks, because a fetch then never waits while it holds another instance's lock. When that change lands, it should add a test to tests/deadlocks (added in #44): two fetches and two instances, which deadlocks on main and passes with the fix. The TLA+ model in #44 covers only one instance, so it does not cover this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions