Skip to content

Commit 9e4a3d8

Browse files
authored
Klima-Cold-Cell-Fetch nicht mehr innerhalb offener DB-Transaktion (#428)
open-meteo-climate.ts hielt den 30s-Archiv-Fetch bislang innerhalb einer pg_advisory_xact_lock-Transaktion offen. Dadurch verlor der Fetch häufig das Rennen gegen seinen eigenen Timeout, sobald die Verbindung mit anderer Pool-Last konkurrierte — betraf vor allem DE, das mit Abstand die meisten distinkten Klimazellen braucht (Coverage dort bei ~18% statt ~97-100% wie in jedem anderen Land). Lock läuft jetzt session-scoped (pg_advisory_lock/ unlock) auf einem eigenen Client, der Fetch selbst läuft außerhalb jeder offenen Transaktion.
1 parent ff2ef36 commit 9e4a3d8

2 files changed

Lines changed: 39 additions & 29 deletions

File tree

server/utils/external-data/open-meteo-climate.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ describe('aggregate', () => {
133133
})
134134

135135
describe('createOpenMeteoClimateNormalsEnhancer / readClimateNormals', () => {
136-
// Models the same-cell serialization a real pg_advisory_xact_lock gives: a
136+
// Models the same-cell serialization a real pg_advisory_lock gives: a
137137
// second lock request for a key already held waits for the holder's
138-
// COMMIT/ROLLBACK before it proceeds. A successful write updates `row` so
139-
// the loser's re-read (inside the lock) sees the winner's cached result.
138+
// pg_advisory_unlock before it proceeds. A successful write updates `row`
139+
// so the loser's re-read (inside the lock) sees the winner's cached result.
140140
function fakePool(existingRow: Record<string, unknown> | null = null) {
141141
const inserted: unknown[][] = []
142142
let row = existingRow
@@ -154,13 +154,12 @@ describe('createOpenMeteoClimateNormalsEnhancer / readClimateNormals', () => {
154154
return vi.fn(async (queryArg: unknown, params: unknown[] = []) => {
155155
const text = typeof queryArg === 'string' ? queryArg : (queryArg as { text: string }).text
156156
const n = text.replace(/\s+/g, ' ').trim().toLowerCase()
157-
if (n === 'begin') return { rows: [], rowCount: 0 }
158-
if (n === 'commit' || n === 'rollback') {
157+
if (n.includes('pg_advisory_unlock')) {
159158
releaseLock?.()
160159
releaseLock = null
161160
return { rows: [], rowCount: 0 }
162161
}
163-
if (n.includes('pg_advisory_xact_lock')) {
162+
if (n.includes('pg_advisory_lock')) {
164163
releaseLock = await acquireLock(String(params[0]))
165164
return { rows: [], rowCount: 0 }
166165
}
@@ -184,16 +183,13 @@ describe('createOpenMeteoClimateNormalsEnhancer / readClimateNormals', () => {
184183
})
185184
}
186185

187-
// drizzle's transaction() (withCellLock) only checks out its own
188-
// connection when the object it wraps looks like a `pg.Pool` — it tests
189-
// `instanceof Pool` or a constructor name containing "Pool" — so this
190-
// needs a named constructor to take that branch and give each concurrent
191-
// lock attempt its own `makeQuery()` closure, matching a real per-session
186+
// withCellLock calls pool.connect() itself (no drizzle transaction
187+
// dispatch involved), so each concurrent lock attempt just needs its own
188+
// `makeQuery()` closure from `connect`, matching a real per-session
192189
// connection.
193-
function MockPool() {}
194190
const query = makeQuery()
195191
const connect = vi.fn(async () => ({ query: makeQuery(), release: vi.fn() }))
196-
const pool = Object.assign(new (MockPool as unknown as new () => object)(), { query, connect, inserted })
192+
const pool = { query, connect, inserted }
197193
return pool as unknown as Pool & { inserted: unknown[][] }
198194
}
199195

server/utils/external-data/open-meteo-climate.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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'
1717
import { sql } from 'drizzle-orm'
1818
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'
1919
import type {
@@ -27,8 +27,8 @@ import { EXTERNAL_DATA_SOURCES } from './sources'
2727
import { 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. */
3232
type Queryable = Pick<NodePgDatabase, 'execute'>
3333

3434
export 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

111120
async 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

122136
function cellLockKey(cell: { lat: number; lon: number }): string {

0 commit comments

Comments
 (0)