Skip to content

Claim jobs by MATERIALIZED CTE instead of locked sub-select#42

Draft
stephanschuler wants to merge 4 commits into
mainfrom
feat/log-retryable-errors
Draft

Claim jobs by MATERIALIZED CTE instead of locked sub-select#42
stephanschuler wants to merge 4 commits into
mainfrom
feat/log-retryable-errors

Conversation

@stephanschuler

@stephanschuler stephanschuler commented Jul 3, 2026

Copy link
Copy Markdown
Member

The candidate row MUST be selected in a MATERIALIZED CTE, never in an
inline FROM (SELECT ... LIMIT 1 FOR UPDATE SKIP LOCKED) subquery.

PostgreSQL is free to place such an inline subquery on the inner side of
a nested-loop join and re-evaluate it once per outer row. Combined with
FOR UPDATE SKIP LOCKED, every re-evaluation skips the rows already
locked by previous iterations and returns the next candidate, so the
join ends up matching - and claiming - more than the single intended row
(all with the same claim value). Whether this happens depends on the
query plan, i.e. on table size and statistics, which is why it only
surfaces on large production tables and not on small dev databases.

AS MATERIALIZED forces the candidate selection to be evaluated exactly
once, so LIMIT 1 reliably bounds the update to a single row.

Doctrine RetryableExceptions are either LockWaitTimeoutException or
DeadlockException. As the name suggests, they should be safe to retry.

This change adds them to the Flow throwable storage anyway, because
having those in abundance, at least when it comes to scheduled jobs,
might be an indicator of bad database layout or network issues.
The candidate row MUST be selected in a `MATERIALIZED` CTE, never in an
inline `FROM (SELECT ... LIMIT 1 FOR UPDATE SKIP LOCKED)` subquery.

PostgreSQL is free to place such an inline subquery on the inner side of
a nested-loop join and re-evaluate it once per outer row. Combined with
`FOR UPDATE SKIP LOCKED`, every re-evaluation skips the rows already
locked by previous iterations and returns the *next* candidate, so the
join ends up matching - and claiming - more than the single intended row
(all with the same claim value). Whether this happens depends on the
query plan, i.e. on table size and statistics, which is why it only
surfaces on large production tables and not on small dev databases.

`AS MATERIALIZED` forces the candidate selection to be evaluated exactly
once, so `LIMIT 1` reliably bounds the update to a single row.
@stephanschuler stephanschuler changed the title Feat/log retryable errors Claim jobs by MATERIALIZED CTE instead of locked sub-select Jul 3, 2026
@stephanschuler
stephanschuler marked this pull request as ready for review July 3, 2026 16:07
The scheduler previously nested two retry mechanisms: an outer Retry
(exponential backoff + logging) in AbstractScheduler around the inner
Connection::withAutoReconnectAndRetry, which caught ConnectionLost and
RetryableException with only a single, non-logged retry attempt.

Retry, exponential backoff and throwable logging now live entirely in
Connection::withAutoReconnectAndRetry. Both ConnectionLost (reconnect
before the next attempt) and RetryableException are retried through the
same path. Callers pass an optional $logContext callable to enrich the
logged data (step, claim, group name).
@stephanschuler
stephanschuler force-pushed the feat/log-retryable-errors branch from 93845e7 to 93abc38 Compare July 3, 2026 17:49
@stephanschuler
stephanschuler requested a review from paxuclus July 3, 2026 17:56
@stephanschuler
stephanschuler marked this pull request as draft July 4, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant