1313// Climate normals don't go stale (WP-7 doc: "einmal geholt, nie wieder"), so
1414// a cell is fetched at most once, ever, keyed by its rounded coordinates.
1515
16- import type { Pool } from 'pg'
16+ import type { Pool , PoolClient } from 'pg'
1717import { sql } from 'drizzle-orm'
1818import { drizzle , type NodePgDatabase } from 'drizzle-orm/node-postgres'
1919import type {
@@ -27,8 +27,8 @@ import { EXTERNAL_DATA_SOURCES } from './sources'
2727import { fetchOpenMeteo } from './open-meteo-rate-limit'
2828
2929/** `readCachedCell`/`writeCachedCell` run both outside any lock (against the
30- * pool directly) and inside `withCellLock`'s transaction (against `tx`) — this
31- * is the common surface both a `NodePgDatabase` and its transaction share. */
30+ * pool directly) and inside `withCellLock`'s locked client, wrapped in its
31+ * own `drizzle()` instance — this is the common surface both share. */
3232type Queryable = Pick < NodePgDatabase , 'execute' >
3333
3434export interface OpenMeteoClimateOptions {
@@ -92,31 +92,45 @@ export async function readClimateNormals(
9292 if ( cached ) return toLocationClimateNormals ( cached , options . checkedAt )
9393
9494 // Cold cell: two concurrent requests can both observe the miss above
95- // before either has written the row. A per-cell advisory lock (held for
96- // the transaction, scoped to the checked-out client) serializes them, and
97- // the re-read after acquiring it lets the loser of the race serve the
98- // winner's freshly-cached row instead of hitting Open-Meteo again.
99- return withCellLock ( db , cell , async ( tx ) => {
100- const recached = await readCachedCell ( tx , cell )
95+ // before either has written the row. A per-cell *session* advisory lock
96+ // (pg_advisory_lock/unlock on a dedicated checked-out client, not
97+ // pg_advisory_xact_lock inside a BEGIN/COMMIT) serializes them without
98+ // pinning that connection inside an open transaction for the whole
99+ // archive fetch below — doing that previously left the fetch racing its
100+ // own abort timeout against everything else contending for the
101+ // connection pool, so cold cells routinely lost that race and never got
102+ // cached at all (observed in prod: DE, with by far the most distinct
103+ // cells to fetch, stuck at ~18% cached vs. 97-100% for every other,
104+ // smaller country). The re-read after acquiring the lock lets the loser
105+ // of the race serve the winner's freshly-cached row instead of hitting
106+ // Open-Meteo again.
107+ return withCellLock ( options . db , cell , async ( client ) => {
108+ const clientDb = drizzle ( client )
109+ const recached = await readCachedCell ( clientDb , cell )
101110 if ( recached ) return toLocationClimateNormals ( recached , options . checkedAt )
102111
103112 const daily = await fetchDailySeries ( cell , options )
104113 if ( ! daily ) return null
105114 const data = aggregate ( daily )
106- await writeCachedCell ( tx , cell , data )
115+ await writeCachedCell ( clientDb , cell , data )
107116 return toLocationClimateNormals ( data , options . checkedAt )
108117 } )
109118}
110119
111120async function withCellLock < T > (
112- db : NodePgDatabase ,
121+ pool : Pool ,
113122 cell : { lat : number ; lon : number } ,
114- fn : ( tx : Queryable ) => Promise < T > ,
123+ fn : ( client : PoolClient ) => Promise < T > ,
115124) : Promise < T > {
116- return db . transaction ( async ( tx ) => {
117- await tx . execute ( sql `SELECT pg_advisory_xact_lock(hashtext(${ cellLockKey ( cell ) } )::bigint)` )
118- return fn ( tx )
119- } )
125+ const client = await pool . connect ( )
126+ const key = cellLockKey ( cell )
127+ try {
128+ await client . query ( 'SELECT pg_advisory_lock(hashtext($1)::bigint)' , [ key ] )
129+ return await fn ( client )
130+ } finally {
131+ await client . query ( 'SELECT pg_advisory_unlock(hashtext($1)::bigint)' , [ key ] ) . catch ( ( ) => { } )
132+ client . release ( )
133+ }
120134}
121135
122136function cellLockKey ( cell : { lat : number ; lon : number } ) : string {
0 commit comments