Skip to content

Payload-dim batch upserts deadlock under concurrent collection cycles: unnest arrays carry no cross-session lock order #1801

Description

@erikdarlingdata

Observed (a production field instance, first night on the v38 dedup write path)

Nine 40P01: deadlock detected errors since the payload-dedup migration activated — zero before it. All self-healed on the collector's next retry: no data loss, no sustained failure, no operator action. Real bug, not an active incident.

The box's own PostgreSQL server log (pg.log) captured the full lock graphs for two occurrences (09:29:09 and 09:44:03 UTC, identical pattern): two processes, each waits for ShareLock on transaction; blocked by the other, both executing the SAME statement shape — PayloadDimensions.UpsertSql against collect.query_text_dim — with the mutual tuple waits on two different specific rows ((108,6) and (293,2)).

Mechanism (verified in source, not just from the logs)

The dim upsert is a batch statement: INSERT ... SELECT FROM unnest($1::bytea[], $2::text[]) ... ON CONFLICT (digest) DO UPDATE .... The arrays it binds come from PayloadDimensionBatch.ToArrays, which drains a Dictionary<string, Entry> via entries.Values — enumeration order, which is per-session arrival order, NOT a consistent total order across sessions.

Two collector sessions (fleet concurrency runs several collection cycles at once) each build their own batch. When both batches contain the same two digests in opposite relative order, each session row-locks its first digest and blocks on its second — a classic unordered-multi-row-upsert lock cycle. The within-batch digest dedup (required to avoid SQLSTATE 21000) is orthogonal: it makes each batch conflict-free with ITSELF, not ordered relative to its SIBLINGS.

Scope

  • collect.query_text_dim — confirmed by lock graphs.
  • collect.query_plan_dim — same shared statement (PayloadDimensions.UpsertSql), same unsorted arrays, identical exposure; fix both via the one central point rather than waiting for its label to show up in a log.
  • collect.module_map — CHECKED AND SAFE, do not "fix" it: its batch upsert (DarlingModuleMap.RefreshSql) carries ORDER BY server_name, sql_handle, collection_time DESC (required by its DISTINCT ON), which is a deterministic ascending order on the conflict key — concurrent runs acquire locks in the same relative order and the cycle cannot form.
  • Every other ON CONFLICT site in the store is single-row (config/state/watermark upserts) — no exposure.

Fix

Impose one deterministic total order on the digests every session uses, at the ONE central point all callers share (the statement or its array producer). Any consistent total order works — byte-wise lexicographic on the digest is the natural one. If all concurrent sessions attempt conflict-key locks in the same relative order, the cycle cannot form. This is the standard fix for this exact shape; it is NOT an indexing problem (the digest PK is required by the dedup semantics), and the hour-throttled conditional DO UPDATE is unrelated and stays.

Pin the property so it cannot regress silently: the ordering is the load-bearing safety mechanism, and it is invisible to every behavioural test (an unordered batch upserts identical DATA — only the lock-acquisition order differs).

Severity

Self-healing under the existing retry, ~9/night at a ~24-server fleet's concurrency. Worth fixing promptly because the collision rate scales with fleet size and digest-overlap (shared plans across servers), and every deadlock is a wasted collection write plus log noise on exactly the surface operators are told to watch.

Activity

  1. added a commit that references this issue on Jul 28, 2026
  2. erikdarlingdata commented on Jul 28, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed by PR #1803 (merged to dev): PayloadDimensionBatch.ToArrays now emits digests in ascending byte order — one deterministic total order at the single point every caller shares, so concurrent collection cycles acquire conflict-key row locks in the same relative order and the cycle cannot form. query_plan_dim is covered by the same edit. module_map confirmed already-safe (its DISTINCT ON's ORDER BY is a deterministic ascending order on the conflict key) and its comment now says so. The ordering is pinned twice (behavioural: adversarial reversed input must emit ascending raw bytes; source: the sort must be present and dictionary enumeration absent), both watched red on a compiling build. Field acceptance on the reporting instance: zero new 40P01 deadlock entries from the dim upsert after the next upgrade — a single occurrence on the fixed build is a real finding, since the fix makes the cycle impossible rather than less likely.

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions