Skip to content

CAP-1073: lock ordering on unique_key to prevent worker/push deadlocks#2

Merged
AudeCstg merged 3 commits into
branch-with-upgradesfrom
ac/cap-1073-lock-ordering
Apr 27, 2026
Merged

CAP-1073: lock ordering on unique_key to prevent worker/push deadlocks#2
AudeCstg merged 3 commits into
branch-with-upgradesfrom
ac/cap-1073-lock-ordering

Conversation

@AudeCstg
Copy link
Copy Markdown

Contexte

Complète le fix de CAP-1073. Le retry sur DeadlockDetected (mergé dans #1) évite le crash du worker mais ne s'attaque pas à la cause racine.

Investigation complète : ~/claude-vault/projects/api/briefs/CAP-1073-investigation-deadlock-chancy.md.

Cause racine

Deux transactions concurrentes sur chancy_jobs acquièrent des row-level locks dans un ordre non déterministe :

  • worker : executemany UPDATE ... WHERE id=? dans _maintain_updates (ordre = arrivée queue interne)
  • push : INSERT ... ON CONFLICT (unique_key) DO UPDATE dans push_many_ex (ordre = construction de la liste)

Quand les deux touchent les mêmes lignes (jobs avec unique_key, typiquement Aircall contact sync), PostgreSQL détecte un cycle → deadlock.

Fix

Tri par unique_key des deux côtés pour garantir un ordre de lock identique → plus de cycle possible.

Changements

  • chancy/worker.py::_maintain_updatespending_updates.sort(key=lambda u: (u.unique_key or "", u.id)) avant executemany. Tiebreaker id pour déterminisme.
  • chancy/app.py::push_many_ex + sync_push_many_ex — tri des jobs par unique_key avec index conservé, puis reconstruction de la liste de Reference dans l'ordre d'entrée original → contrat public préservé.
  • Helper _job_unique_key partagé entre async et sync (gère Job | IsAJob).

Autres endroits audités

Tous les autres UPDATE/INSERT sur chancy_jobs sont safe (pas de patch nécessaire) :

Endroit Raison
cancel_job UPDATE sur 1 seul id
fetch_jobs (worker) CTE + FOR UPDATE SKIP LOCKED + ORDER BY
reprioritize plugin CTE + FOR UPDATE SKIP LOCKED + ORDER BY id
metrics plugin Table metrics, pas jobs
cron plugin Table cron, pas jobs

Coût / risques

  • Tri Python Timsort O(n log n), ~1 ms pour 1000 éléments → négligeable vs les centaines de ms de la transaction SQL.
  • Non-breaking : aucun changement d'API publique, références retournées dans l'ordre entrant.
  • Pas de test ajouté : le deadlock nécessite du vrai load concurrent (validation en staging).

Test plan

  • Déployer en staging depuis le fork ouihelp
  • Vérifier qu'aucun crash DeadlockDetected ne remonte sur Sentry pendant 48h
  • Monitorer [api-chancy-worker] worker is down — le flap doit cesser
  • Si stable : proposer la PR en upstream sur TkTech/chancy

Enforce a deterministic lock ordering on chancy_jobs rows so that
concurrent push_many_ex INSERT ... ON CONFLICT transactions cannot
deadlock with the worker batch update. Sorting by (unique_key, id)
guarantees both sides acquire row-level exclusive locks in the same
order, eliminating the circular wait detected in CAP-1073.
Jobs sharing a unique_key collide via ON CONFLICT DO UPDATE with rows
being updated by the worker's _maintain_updates batch. Without a
deterministic lock order, the two transactions can acquire row locks
in opposite orders and deadlock (CAP-1073). Sorting the inserts by
unique_key matches the ordering now applied worker-side, removing the
cycle. References are placed back in the caller's original order so
the public return contract is unchanged.
@linear
Copy link
Copy Markdown

linear Bot commented Apr 17, 2026

@AudeCstg AudeCstg requested a review from ppsmilesi April 17, 2026 13:10
@AudeCstg AudeCstg marked this pull request as ready for review April 17, 2026 13:10
Comment thread chancy/app.py Outdated

@staticmethod
def _job_unique_key(job: Job | IsAJob[..., Any]) -> str:
if callable(job):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c'est comme ça qu'il fait ailleurs ou il utilise isinstance(...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aligné sur isinstance(job, Job) (cohérent avec le call site existant ligne 694) dans af32351.

Comment thread chancy/app.py Outdated
)
record = await cursor.fetchone()
references.append(Reference(record["id"]))
references[original_index] = Reference(record["id"])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mypy doit râler que references: list[Reference | None] mais que le retour de la fonction soit list[Reference]. Faut surement caster ou utiliser un dict intermédiaire.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bien vu. Remplacé par un dict[int, Reference] intermédiaire dans af32351, le retour est maintenant un list[Reference] propre, sans cast.

@ppsmilesi ppsmilesi self-requested a review April 22, 2026 13:36
Copy link
Copy Markdown

@ppsmilesi ppsmilesi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je pré-approuve

- Use isinstance(job, Job) in _job_unique_key for consistency with the
  pattern already used at the call site in push_many_ex.
- Replace pre-allocated list[Reference | None] with a dict[int, Reference]
  intermediate so the function returns a clean list[Reference] without
  needing a cast or carrying Optionals through to mypy.
@AudeCstg AudeCstg merged commit 7acaac1 into branch-with-upgrades Apr 27, 2026
3 of 39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants