Fix deadlock in concurrent tracker mapping

The tracker-mapping worker runs many Process calls in parallel. In
Phase 4 a single transaction locked the worker's own claimed pattern
row via UpdateMapping and then locked sibling rows on the same banner
via the re-enqueue. Two workers mapping sibling patterns on one banner
each held their own row and waited on the other's, forming a lock cycle
that Postgres aborted with deadlock detected (40P01).

Split the sibling re-enqueue into its own short transaction that runs
after the mapping commits, so the claimed-row lock is released before
any sibling row is locked. Also take the sibling UPDATE row locks in a
deterministic id order through an ORDER BY id ... FOR UPDATE subquery,
so overlapping re-enqueues can no longer invert lock order between
themselves. The re-enqueue only flags siblings, so deferring it past
the commit is safe and lets reprocessed siblings observe committed data.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-19 09:59:36 +02:00
parent 4435a93eca
commit a4cc82441a
2 changed files with 62 additions and 28 deletions

View File

@@ -289,7 +289,9 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
// Phase 4: persist the pattern mapping in a short transaction. The
// unmatched fallback keeps catalog coverage complete even when no
// vendor was resolved.
return h.pg.WithTx(
mapped := true
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if commonPatternID == nil {
@@ -331,6 +333,8 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
log.String("tracker_pattern_id", tp.ID.String()),
)
mapped = false
return nil
}
@@ -344,20 +348,39 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
log.String("tracker_pattern_id", tp.ID.String()),
)
// This run newly resolved a catalog third party, so
// same-banner siblings that share an initiator domain but
// were processed earlier and left unmatched can now match
// against it. Re-arm their mapping so the worker revisits
// them; the guards keep already-mapped siblings untouched.
if commonThirdPartyID != nil && !det.commonThirdPartyPreexisted {
if err := h.reenqueueUnmappedSiblings(ctx, tx, tp, det.domains); err != nil {
return err
}
}
return nil
},
)
); err != nil {
return err
}
// Phase 5: re-arm same-banner siblings in a separate short
// transaction, after the mapping above has committed. This run newly
// resolved a catalog third party, so siblings that share an initiator
// domain but were processed earlier and left unmatched can now match
// against it. Re-arm their mapping so the worker revisits them; the
// guards keep already-mapped siblings untouched.
//
// The re-enqueue must not run inside the Phase 4 transaction: that
// transaction holds the row lock on tp, and the sibling UPDATE then
// takes locks on other tracker_patterns rows while holding it. Two
// workers mapping sibling patterns on the same banner would acquire
// those row locks in opposite orders and deadlock. Committing Phase 4
// first releases tp's lock, and RequestMappingForUnmappedSiblings
// takes its locks in a deterministic id order, so the two can no
// longer cycle.
if mapped && commonThirdPartyID != nil && !det.commonThirdPartyPreexisted {
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return h.reenqueueUnmappedSiblings(ctx, tx, tp, det.domains)
},
); err != nil {
return err
}
}
return nil
}
// deterministicResult carries the outcome of the pure-SQL catalog

View File

@@ -1286,26 +1286,37 @@ func (tps *TrackerPatterns) RequestMappingForUnmappedSiblings(
return 0, nil
}
// The target rows are locked through an ORDER BY id ... FOR UPDATE
// subquery so concurrent re-enqueues over overlapping sibling sets
// always acquire their row locks in the same ascending id order. Two
// workers mapping sibling patterns on the same banner would otherwise
// lock the shared rows in opposite orders and deadlock (40P01).
q := `
UPDATE tracker_patterns
SET
mapping_requested_at = NOW(),
updated_at = NOW()
WHERE
%[1]s
AND cookie_banner_id = @cookie_banner_id
AND id != @exclude_pattern_id
AND third_party_id IS NULL
AND mapping_requested_at IS NULL
AND (source IS NULL OR source != @extension_source)
AND id IN (
SELECT DISTINCT tracker_pattern_id
FROM detected_trackers
WHERE %[1]s
AND cookie_banner_id = @cookie_banner_id
AND initiator_domain = ANY(@domains)
AND tracker_pattern_id IS NOT NULL
)
WHERE id IN (
SELECT id
FROM tracker_patterns
WHERE
%[1]s
AND cookie_banner_id = @cookie_banner_id
AND id != @exclude_pattern_id
AND third_party_id IS NULL
AND mapping_requested_at IS NULL
AND (source IS NULL OR source != @extension_source)
AND id IN (
SELECT DISTINCT tracker_pattern_id
FROM detected_trackers
WHERE %[1]s
AND cookie_banner_id = @cookie_banner_id
AND initiator_domain = ANY(@domains)
AND tracker_pattern_id IS NOT NULL
)
ORDER BY id
FOR UPDATE
)
`
q = fmt.Sprintf(q, scope.SQLFragment())