[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-en-postgres-advisory-lock-connection-pool-leak":3},{"id":4,"title":5,"author":6,"body":7,"cover":1046,"description":1047,"draft":1048,"extension":1049,"locale":1050,"meta":1051,"navigation":127,"path":1052,"publishedAt":1053,"seo":1054,"seoTitle":1055,"slug":1056,"stem":1057,"tags":1058,"__hash__":1062},"blog\u002Fen\u002Fblog\u002Fpostgres-advisory-lock-connection-pool-leak.md","Advisory locks and connection pools: the silent leak (and how to avoid it)","Manuel Tardivo",{"type":8,"value":9,"toc":1027},"minimark",[10,15,38,41,83,94,98,105,176,179,188,196,203,208,233,254,257,265,286,294,301,315,318,343,360,392,407,427,438,444,455,517,524,530,558,562,581,586,599,605,610,624,643,660,664,685,692,859,864,930,937,941,951,955,979,983,1023],[11,12,14],"h2",{"id":13},"who-this-is-for","Who this is for",[16,17,18,19,23,24,28,29,32,33,37],"p",{},"If ",[20,21,22],"strong",{},"workers sometimes run the same job twice",", or you are designing a Postgres-backed queue, you probably already know what advisory locks and ",[25,26,27],"code",{},"SELECT … FOR UPDATE"," are. What is ",[20,30,31],{},"hard"," to find is ",[34,35,36],"em",{},"when the mechanism you picked quietly fails in production"," and how to notice before the symptom is duplicate side effects or “stuck” traffic.",[16,39,40],{},"This post focuses on three concrete gaps:",[42,43,44,55,76],"ol",{},[45,46,47,50,51,54],"li",{},[20,48,49],{},"Session-scoped advisory locks + a pool that reuses the same Postgres backend across transactions"," (classic ",[20,52,53],{},"PgBouncer transaction pooling",").",[45,56,57,58,64,65,68,69,72,73,54],{},"Why ",[20,59,60,63],{},[25,61,62],{},"SKIP LOCKED"," is the wrong primitive"," when you need ",[20,66,67],{},"mutual exclusion on one known key"," (you want ",[20,70,71],{},"wait",", not ",[20,74,75],{},"skip",[45,77,78,79,82],{},"A ",[20,80,81],{},"ready-to-paste monitoring query"," for long advisory waits, with column-by-column intent for alerting.",[16,84,85,86,89,90,93],{},"Version note: examples match ",[20,87,88],{},"PostgreSQL 16","; on other majors, double-check ",[25,91,92],{},"pg_locks"," columns and your pooler docs.",[11,95,97],{"id":96},"minimal-ledger-setup","Minimal ledger setup",[16,99,100,101,104],{},"You still want a stable row when we contrast with ",[25,102,103],{},"FOR UPDATE",":",[106,107,112],"pre",{"className":108,"code":109,"language":110,"meta":111,"style":111},"language-sql shiki shiki-themes github-light github-dark","DROP TABLE IF EXISTS job_ledger;\n\nCREATE TABLE job_ledger (\n  job_key     text PRIMARY KEY,\n  claim_count bigint NOT NULL DEFAULT 0,\n  updated_at  timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO job_ledger (job_key) VALUES ('report-daily')\nON CONFLICT DO NOTHING;\n","sql","",[25,113,114,122,129,135,141,147,153,159,164,170],{"__ignoreMap":111},[115,116,119],"span",{"class":117,"line":118},"line",1,[115,120,121],{},"DROP TABLE IF EXISTS job_ledger;\n",[115,123,125],{"class":117,"line":124},2,[115,126,128],{"emptyLinePlaceholder":127},true,"\n",[115,130,132],{"class":117,"line":131},3,[115,133,134],{},"CREATE TABLE job_ledger (\n",[115,136,138],{"class":117,"line":137},4,[115,139,140],{},"  job_key     text PRIMARY KEY,\n",[115,142,144],{"class":117,"line":143},5,[115,145,146],{},"  claim_count bigint NOT NULL DEFAULT 0,\n",[115,148,150],{"class":117,"line":149},6,[115,151,152],{},"  updated_at  timestamptz NOT NULL DEFAULT now()\n",[115,154,156],{"class":117,"line":155},7,[115,157,158],{},");\n",[115,160,162],{"class":117,"line":161},8,[115,163,128],{"emptyLinePlaceholder":127},[115,165,167],{"class":117,"line":166},9,[115,168,169],{},"INSERT INTO job_ledger (job_key) VALUES ('report-daily')\n",[115,171,173],{"class":117,"line":172},10,[115,174,175],{},"ON CONFLICT DO NOTHING;\n",[16,177,178],{},"Shared advisory class id for your app namespace:",[106,180,182],{"className":108,"code":181,"language":110,"meta":111,"style":111},"SELECT 7742 AS app_advisory_class;\n",[25,183,184],{"__ignoreMap":111},[115,185,186],{"class":117,"line":118},[115,187,181],{},[11,189,191,192,195],{"id":190},"sanity-check-pg_advisory_xact_lock-behaves","Sanity check: ",[25,193,194],{},"pg_advisory_xact_lock"," behaves",[16,197,198,199,202],{},"Two ",[25,200,201],{},"psql"," windows.",[16,204,205],{},[20,206,207],{},"Session A",[106,209,211],{"className":108,"code":210,"language":110,"meta":111,"style":111},"BEGIN;\nSELECT pg_advisory_xact_lock(7742, hashtext('report-daily'));\n-- … work …\nCOMMIT;\n",[25,212,213,218,223,228],{"__ignoreMap":111},[115,214,215],{"class":117,"line":118},[115,216,217],{},"BEGIN;\n",[115,219,220],{"class":117,"line":124},[115,221,222],{},"SELECT pg_advisory_xact_lock(7742, hashtext('report-daily'));\n",[115,224,225],{"class":117,"line":131},[115,226,227],{},"-- … work …\n",[115,229,230],{"class":117,"line":137},[115,231,232],{},"COMMIT;\n",[16,234,235,238,239,242,243,245,246,249,250,253],{},[20,236,237],{},"Session B"," (same ",[25,240,241],{},"BEGIN"," + same ",[25,244,194],{}," while A has not committed): ",[20,247,248],{},"blocks",". After A commits, B proceeds. The lock is ",[20,251,252],{},"transaction-scoped"," — nothing remains on the backend after the transaction ends.",[16,255,256],{},"This is textbook Postgres — it only sets the baseline before the painful case.",[11,258,260,261,264],{"id":259},"the-painful-case-pg_advisory_lock-and-transaction-mode-pooling","The painful case: ",[25,262,263],{},"pg_advisory_lock"," and transaction-mode pooling",[16,266,267,269,270,273,274,277,278,281,282,285],{},[25,268,263],{}," (non-",[25,271,272],{},"xact",") is ",[20,275,276],{},"session-scoped",": it survives ",[25,279,280],{},"COMMIT"," until ",[25,283,284],{},"pg_advisory_unlock"," or disconnect.",[287,288,290,291,293],"h3",{"id":289},"reproduce-without-pgbouncer-one-backend-one-psql","Reproduce without PgBouncer (one backend, one ",[25,292,201],{},")",[16,295,296,297,300],{},"In a ",[20,298,299],{},"single"," session:",[106,302,304],{"className":108,"code":303,"language":110,"meta":111,"style":111},"SELECT pg_advisory_lock(7742, hashtext('report-daily'));\nCOMMIT;\n",[25,305,306,311],{"__ignoreMap":111},[115,307,308],{"class":117,"line":118},[115,309,310],{},"SELECT pg_advisory_lock(7742, hashtext('report-daily'));\n",[115,312,313],{"class":117,"line":124},[115,314,232],{},[16,316,317],{},"Then ask Postgres whether the lock is still there:",[106,319,321],{"className":108,"code":320,"language":110,"meta":111,"style":111},"SELECT l.pid, l.mode, l.granted, l.classid, l.objid\nFROM pg_locks l\nWHERE l.locktype = 'advisory'\n  AND l.pid = pg_backend_pid();\n",[25,322,323,328,333,338],{"__ignoreMap":111},[115,324,325],{"class":117,"line":118},[115,326,327],{},"SELECT l.pid, l.mode, l.granted, l.classid, l.objid\n",[115,329,330],{"class":117,"line":124},[115,331,332],{},"FROM pg_locks l\n",[115,334,335],{"class":117,"line":131},[115,336,337],{},"WHERE l.locktype = 'advisory'\n",[115,339,340],{"class":117,"line":137},[115,341,342],{},"  AND l.pid = pg_backend_pid();\n",[16,344,345,348,349,352,353,355,356,359],{},[20,346,347],{},"Expected:"," at least one row with ",[25,350,351],{},"granted = true",". ",[25,354,280],{}," ",[20,357,358],{},"did not"," release the lock — that is correct for the session-scoped variant.",[16,361,362,363,365,366,368,369,372,373,376,377,380,381,384,385,387,388,391],{},"Now picture ",[20,364,53],{}," (or an equivalent pool): after ",[25,367,280],{}," your app ",[20,370,371],{},"returns the connection to the pool",". The ",[20,374,375],{},"server backend"," (",[25,378,379],{},"pid"," above) often ",[20,382,383],{},"stays allocated"," to the next client that gets that pooled server slot. The advisory lock is still attached to that ",[25,386,379],{},": the next worker may inherit a lock it ",[20,389,390],{},"never requested",". Symptoms: “ghost” serialization, starvation, or cross-tenant bleed that never reproduces on a single local connection.",[16,393,394,399,400,402,403,406],{},[20,395,57,396,398],{},[25,397,194],{}," fixes the pooling story:"," it releases at transaction end, so the backend is clean for the next pool renter after every ",[25,401,280],{},"\u002F",[25,404,405],{},"ROLLBACK"," — the lifecycle transaction pooling assumes.",[408,409,410],"blockquote",{},[16,411,412,415,416,419,420,422,423,426],{},[20,413,414],{},"Note:"," in ",[20,417,418],{},"session pooling",", a client usually owns one backend for its whole life, so cross-client inheritance is rarer, but you still leak until disconnect if you forget ",[25,421,284],{},". In ",[20,424,425],{},"transaction mode",", backend reuse across clients is the default.",[16,428,429,430,437],{},"Useful reading: ",[431,432,436],"a",{"href":433,"rel":434},"https:\u002F\u002Fwww.pgbouncer.org\u002Ffeatures.html",[435],"nofollow","PgBouncer features — pooling mode",".",[11,439,441,443],{"id":440},"for-update-on-the-ledger-when-lock-and-state-must-be-the-same-transaction",[25,442,103],{}," on the ledger: when lock and state must be the same transaction",[16,445,446,447,450,451,454],{},"If serialization must be ",[20,448,449],{},"the same transaction"," that updates ",[25,452,453],{},"claim_count"," \u002F status, a ledger row is a common pattern:",[106,456,458],{"className":108,"code":457,"language":110,"meta":111,"style":111},"BEGIN;\n\nSELECT job_key, claim_count\nFROM job_ledger\nWHERE job_key = 'report-daily'\nFOR UPDATE;\n\nUPDATE job_ledger\nSET claim_count = claim_count + 1, updated_at = now()\nWHERE job_key = 'report-daily';\n\nCOMMIT;\n",[25,459,460,464,468,473,478,483,488,492,497,502,507,512],{"__ignoreMap":111},[115,461,462],{"class":117,"line":118},[115,463,217],{},[115,465,466],{"class":117,"line":124},[115,467,128],{"emptyLinePlaceholder":127},[115,469,470],{"class":117,"line":131},[115,471,472],{},"SELECT job_key, claim_count\n",[115,474,475],{"class":117,"line":137},[115,476,477],{},"FROM job_ledger\n",[115,479,480],{"class":117,"line":143},[115,481,482],{},"WHERE job_key = 'report-daily'\n",[115,484,485],{"class":117,"line":149},[115,486,487],{},"FOR UPDATE;\n",[115,489,490],{"class":117,"line":155},[115,491,128],{"emptyLinePlaceholder":127},[115,493,494],{"class":117,"line":161},[115,495,496],{},"UPDATE job_ledger\n",[115,498,499],{"class":117,"line":166},[115,500,501],{},"SET claim_count = claim_count + 1, updated_at = now()\n",[115,503,504],{"class":117,"line":172},[115,505,506],{},"WHERE job_key = 'report-daily';\n",[115,508,510],{"class":117,"line":509},11,[115,511,128],{"emptyLinePlaceholder":127},[115,513,515],{"class":117,"line":514},12,[115,516,232],{},[16,518,519,520,523],{},"Here the lock ",[20,521,522],{},"is"," the row you mutate — no “advisory first, update later” gap.",[11,525,527,529],{"id":526},"skip-locked-why-it-is-not-the-mutex-you-want-for-one-key",[25,528,62],{},": why it is not the mutex you want for one key",[16,531,532,534,535,538,539,542,543,546,547,550,551,554,555,557],{},[25,533,62],{}," means ",[20,536,537],{},"do not wait"," for rows already locked — the reader ",[20,540,541],{},"moves on immediately"," without those rows. For ",[20,544,545],{},"one known job key"," that must be processed by ",[20,548,549],{},"at most one worker at a time",", that is often the ",[20,552,553],{},"opposite"," of what you want: you want the second worker to ",[20,556,71],{},", not to “not see” the work.",[16,559,560],{},[20,561,207],{},[106,563,565],{"className":108,"code":564,"language":110,"meta":111,"style":111},"BEGIN;\nSELECT job_key FROM job_ledger WHERE job_key = 'report-daily' FOR UPDATE;\n-- hold; do not COMMIT yet\n",[25,566,567,571,576],{"__ignoreMap":111},[115,568,569],{"class":117,"line":118},[115,570,217],{},[115,572,573],{"class":117,"line":124},[115,574,575],{},"SELECT job_key FROM job_ledger WHERE job_key = 'report-daily' FOR UPDATE;\n",[115,577,578],{"class":117,"line":131},[115,579,580],{},"-- hold; do not COMMIT yet\n",[16,582,583,585],{},[20,584,237],{}," — first without skip:",[106,587,589],{"className":108,"code":588,"language":110,"meta":111,"style":111},"BEGIN;\nSELECT job_key FROM job_ledger WHERE job_key = 'report-daily' FOR UPDATE;\n",[25,590,591,595],{"__ignoreMap":111},[115,592,593],{"class":117,"line":118},[115,594,217],{},[115,596,597],{"class":117,"line":124},[115,598,575],{},[16,600,601,602,604],{},"Expected: ",[20,603,248],{}," until A commits. Classic serialization.",[16,606,607,609],{},[25,608,405],{}," on B, then retry B with:",[106,611,613],{"className":108,"code":612,"language":110,"meta":111,"style":111},"BEGIN;\nSELECT job_key FROM job_ledger WHERE job_key = 'report-daily' FOR UPDATE SKIP LOCKED;\n",[25,614,615,619],{"__ignoreMap":111},[115,616,617],{"class":117,"line":118},[115,618,217],{},[115,620,621],{"class":117,"line":124},[115,622,623],{},"SELECT job_key FROM job_ledger WHERE job_key = 'report-daily' FOR UPDATE SKIP LOCKED;\n",[16,625,626,627,630,631,634,635,638,639,642],{},"Expected: the statement ",[20,628,629],{},"returns immediately"," with ",[20,632,633],{},"zero rows"," (there is no alternate unlocked row): the worker may read “no job” while the job ",[20,636,637],{},"exists"," and is merely locked. That is the footgun for ",[20,640,641],{},"single-key"," idempotency.",[408,644,645],{},[16,646,647,648,651,652,655,656,659],{},"Outbox \u002F claim-pool designs ",[20,649,650],{},"want"," to skip rows others already claimed across a ",[20,653,654],{},"set"," of jobs. This article is about ",[20,657,658],{},"one"," key that must be mutually exclusive, not “fetch the next free row”.",[11,661,663],{"id":662},"production-ready-monitoring-advisory-waits-longer-than-n-seconds","Production-ready monitoring: advisory waits longer than N seconds",[16,665,666,667,670,671,355,674,676,677,680,681,684],{},"Goal: feed an ",[20,668,669],{},"alert"," (“a backend has waited too long on an advisory”). Join ",[20,672,673],{},"ungranted",[25,675,92],{}," to ",[25,678,679],{},"pg_stat_activity",", then to the ",[20,682,683],{},"granted"," holder using the lock identity columns.",[16,686,687,688,691],{},"Example threshold: ",[20,689,690],{},"30 seconds"," — tune in prod.",[106,693,695],{"className":108,"code":694,"language":110,"meta":111,"style":111},"SELECT\n  blocked_a.pid AS blocked_pid,\n  now() - blocked_a.query_start AS blocked_since,\n  blocked_a.state AS blocked_state,\n  blocked_a.wait_event_type,\n  blocked_a.wait_event,\n  left(blocked_a.query, 200) AS blocked_query_snippet,\n  blocking_a.pid AS blocking_pid,\n  now() - blocking_a.query_start AS blocking_query_age,\n  left(blocking_a.query, 200) AS blocking_query_snippet\nFROM pg_catalog.pg_locks blocked_l\nJOIN pg_catalog.pg_stat_activity blocked_a ON blocked_a.pid = blocked_l.pid\nJOIN pg_catalog.pg_locks blocking_l\n  ON blocking_l.locktype = blocked_l.locktype\n AND blocking_l.database IS NOT DISTINCT FROM blocked_l.database\n AND blocking_l.relation IS NOT DISTINCT FROM blocked_l.relation\n AND blocking_l.page IS NOT DISTINCT FROM blocked_l.page\n AND blocking_l.tuple IS NOT DISTINCT FROM blocked_l.tuple\n AND blocking_l.virtualxid IS NOT DISTINCT FROM blocked_l.virtualxid\n AND blocking_l.transactionid IS NOT DISTINCT FROM blocked_l.transactionid\n AND blocking_l.classid IS NOT DISTINCT FROM blocked_l.classid\n AND blocking_l.objid IS NOT DISTINCT FROM blocked_l.objid\n AND blocking_l.objsubid IS NOT DISTINCT FROM blocked_l.objsubid\n AND blocking_l.pid \u003C> blocked_l.pid\nJOIN pg_catalog.pg_stat_activity blocking_a ON blocking_a.pid = blocking_l.pid\nWHERE NOT blocked_l.granted\n  AND blocking_l.granted\n  AND blocked_l.locktype = 'advisory'\n  AND now() - blocked_a.query_start > interval '30 seconds';\n",[25,696,697,702,707,712,717,722,727,732,737,742,747,752,757,763,769,775,781,787,793,799,805,811,817,823,829,835,841,847,853],{"__ignoreMap":111},[115,698,699],{"class":117,"line":118},[115,700,701],{},"SELECT\n",[115,703,704],{"class":117,"line":124},[115,705,706],{},"  blocked_a.pid AS blocked_pid,\n",[115,708,709],{"class":117,"line":131},[115,710,711],{},"  now() - blocked_a.query_start AS blocked_since,\n",[115,713,714],{"class":117,"line":137},[115,715,716],{},"  blocked_a.state AS blocked_state,\n",[115,718,719],{"class":117,"line":143},[115,720,721],{},"  blocked_a.wait_event_type,\n",[115,723,724],{"class":117,"line":149},[115,725,726],{},"  blocked_a.wait_event,\n",[115,728,729],{"class":117,"line":155},[115,730,731],{},"  left(blocked_a.query, 200) AS blocked_query_snippet,\n",[115,733,734],{"class":117,"line":161},[115,735,736],{},"  blocking_a.pid AS blocking_pid,\n",[115,738,739],{"class":117,"line":166},[115,740,741],{},"  now() - blocking_a.query_start AS blocking_query_age,\n",[115,743,744],{"class":117,"line":172},[115,745,746],{},"  left(blocking_a.query, 200) AS blocking_query_snippet\n",[115,748,749],{"class":117,"line":509},[115,750,751],{},"FROM pg_catalog.pg_locks blocked_l\n",[115,753,754],{"class":117,"line":514},[115,755,756],{},"JOIN pg_catalog.pg_stat_activity blocked_a ON blocked_a.pid = blocked_l.pid\n",[115,758,760],{"class":117,"line":759},13,[115,761,762],{},"JOIN pg_catalog.pg_locks blocking_l\n",[115,764,766],{"class":117,"line":765},14,[115,767,768],{},"  ON blocking_l.locktype = blocked_l.locktype\n",[115,770,772],{"class":117,"line":771},15,[115,773,774],{}," AND blocking_l.database IS NOT DISTINCT FROM blocked_l.database\n",[115,776,778],{"class":117,"line":777},16,[115,779,780],{}," AND blocking_l.relation IS NOT DISTINCT FROM blocked_l.relation\n",[115,782,784],{"class":117,"line":783},17,[115,785,786],{}," AND blocking_l.page IS NOT DISTINCT FROM blocked_l.page\n",[115,788,790],{"class":117,"line":789},18,[115,791,792],{}," AND blocking_l.tuple IS NOT DISTINCT FROM blocked_l.tuple\n",[115,794,796],{"class":117,"line":795},19,[115,797,798],{}," AND blocking_l.virtualxid IS NOT DISTINCT FROM blocked_l.virtualxid\n",[115,800,802],{"class":117,"line":801},20,[115,803,804],{}," AND blocking_l.transactionid IS NOT DISTINCT FROM blocked_l.transactionid\n",[115,806,808],{"class":117,"line":807},21,[115,809,810],{}," AND blocking_l.classid IS NOT DISTINCT FROM blocked_l.classid\n",[115,812,814],{"class":117,"line":813},22,[115,815,816],{}," AND blocking_l.objid IS NOT DISTINCT FROM blocked_l.objid\n",[115,818,820],{"class":117,"line":819},23,[115,821,822],{}," AND blocking_l.objsubid IS NOT DISTINCT FROM blocked_l.objsubid\n",[115,824,826],{"class":117,"line":825},24,[115,827,828],{}," AND blocking_l.pid \u003C> blocked_l.pid\n",[115,830,832],{"class":117,"line":831},25,[115,833,834],{},"JOIN pg_catalog.pg_stat_activity blocking_a ON blocking_a.pid = blocking_l.pid\n",[115,836,838],{"class":117,"line":837},26,[115,839,840],{},"WHERE NOT blocked_l.granted\n",[115,842,844],{"class":117,"line":843},27,[115,845,846],{},"  AND blocking_l.granted\n",[115,848,850],{"class":117,"line":849},28,[115,851,852],{},"  AND blocked_l.locktype = 'advisory'\n",[115,854,856],{"class":117,"line":855},29,[115,857,858],{},"  AND now() - blocked_a.query_start > interval '30 seconds';\n",[16,860,861],{},[20,862,863],{},"How to read each column for alerting:",[865,866,867,875,883,906,914,922],"ul",{},[45,868,869,874],{},[20,870,871],{},[25,872,873],{},"blocked_pid",": Postgres backend of the waiting session.",[45,876,877,882],{},[20,878,879],{},[25,880,881],{},"blocked_since",": how long that statement has been running — a practical proxy for lock wait duration.",[45,884,885,897,898,901,902,905],{},[20,886,887,890,891,890,894],{},[25,888,889],{},"blocked_state"," \u002F ",[25,892,893],{},"wait_event_type",[25,895,896],{},"wait_event",": lock waits are usually ",[25,899,900],{},"active"," + ",[25,903,904],{},"Lock"," with an advisory-related wait event; helps separate lock waits from I\u002FO waits.",[45,907,908,913],{},[20,909,910],{},[25,911,912],{},"blocked_query_snippet",": truncated SQL text (watch PII in logs).",[45,915,916,921],{},[20,917,918],{},[25,919,920],{},"blocking_pid",": the session that holds the granted advisory lock.",[45,923,924,929],{},[20,925,926],{},[25,927,928],{},"blocking_query_snippet",": what the holder is doing — often the long transaction to optimize or cap with a timeout.",[16,931,932,933,936],{},"In noisy systems, add ",[25,934,935],{},"AND blocked_l.classid = 7742"," if that class id is reserved for your job locks.",[11,938,940],{"id":939},"out-of-scope","Out of scope",[16,942,943,944,947,948,950],{},"Patroni, failover, ORMs, ",[25,945,946],{},"LISTEN\u002FNOTIFY",", outbox claim tables with ",[25,949,62],{}," across many rows — valid topics, different stories.",[11,952,954],{"id":953},"closing","Closing",[16,956,957,958,960,961,967,968,971,972,974,975,978],{},"The useful question is not a generic “advisory vs ",[25,959,103],{},"” matrix, but: ",[20,962,963,964,966],{},"does my lock survive ",[25,965,280],{}," in a way the pool hands to the next client?"," and ",[20,969,970],{},"does my worker treat “zero rows” as “no work” when the work row is merely locked?"," Shipping ",[25,973,263],{}," under transaction pooling without knowing this is a ",[20,976,977],{},"silent"," failure mode until you measure contention or accidental cross-job reuse.",[11,980,982],{"id":981},"references","References",[865,984,985,992,1002,1017],{},[45,986,987],{},[431,988,991],{"href":989,"rel":990},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fexplicit-locking.html#ADVISORY-LOCKS",[435],"Explicit locking — Advisory locks",[45,993,994],{},[431,995,998,890,1000],{"href":996,"rel":997},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Ffunctions-admin.html#FUNCTIONS-ADVISORY-LOCKS",[435],[25,999,263],{},[25,1001,194],{},[45,1003,1004,1010,1011],{},[431,1005,1008],{"href":1006,"rel":1007},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fview-pg-locks.html",[435],[25,1009,92],{},", ",[431,1012,1015],{"href":1013,"rel":1014},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fmonitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW",[435],[25,1016,679],{},[45,1018,1019],{},[431,1020,1022],{"href":433,"rel":1021},[435],"PgBouncer pooling modes",[1024,1025,1026],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":111,"searchDepth":124,"depth":124,"links":1028},[1029,1030,1031,1033,1038,1040,1042,1043,1044,1045],{"id":13,"depth":124,"text":14},{"id":96,"depth":124,"text":97},{"id":190,"depth":124,"text":1032},"Sanity check: pg_advisory_xact_lock behaves",{"id":259,"depth":124,"text":1034,"children":1035},"The painful case: pg_advisory_lock and transaction-mode pooling",[1036],{"id":289,"depth":131,"text":1037},"Reproduce without PgBouncer (one backend, one psql)",{"id":440,"depth":124,"text":1039},"FOR UPDATE on the ledger: when lock and state must be the same transaction",{"id":526,"depth":124,"text":1041},"SKIP LOCKED: why it is not the mutex you want for one key",{"id":662,"depth":124,"text":663},{"id":939,"depth":124,"text":940},{"id":953,"depth":124,"text":954},{"id":981,"depth":124,"text":982},"\u002Fblog\u002F31_postgres_advisory_lock_connection_pool_leak_1x.webp","Why a session-scoped pg_advisory_lock survives COMMIT and breaks transaction-mode pools, how to reproduce it, and what to use instead of SKIP LOCKED.",false,"md","en",{},"\u002Fen\u002Fblog\u002Fpostgres-advisory-lock-connection-pool-leak","2026-06-13",{"title":5,"description":1047},"Postgres advisory locks and pool leaks","postgres-advisory-lock-connection-pool-leak","en\u002Fblog\u002Fpostgres-advisory-lock-connection-pool-leak",[1059,1060,1061],"postgres","mvp","automation","AIq3hNvN9eyJ-7CAY4iQ_saPoLHKg_6sTy3YhGoo1Ho"]