fix: atomic goal updates in sync — transaction + bulk INSERT

Previously each match goal sync did: DELETE (auto-commit) → N
individual INSERTs (each auto-commit). During those ~50ms readers
saw 0 goals for the match — the inconsistency window.

Now: collectGoals() builds the rows in memory, replaceGoals() wraps
the DELETE + single bulk VALUES INSERT in a transaction. Under
Postgres READ COMMITTED, readers see the old goals until commit and
the full new set after — never an empty window.

Also drop sync pool from max:5 → max:2; the job is fully sequential
and was holding idle connections unnecessarily.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:56:43 +02:00
parent 2c981dc6c0
commit 83b1ad3e35
+35 -13
View File
@@ -37,7 +37,7 @@ function parseScore(score: RawScore | undefined) {
} }
async function run() { async function run() {
const client = postgres(DATABASE_URL!, { max: 5 }) const client = postgres(DATABASE_URL!, { max: 2 })
const db = drizzle(client) const db = drizzle(client)
const teamCache = new Map<string, number>() const teamCache = new Map<string, number>()
@@ -94,17 +94,32 @@ async function run() {
return (rows[0] as { id: number }).id return (rows[0] as { id: number }).id
} }
async function syncGoals(matchId: number, teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number) { type GoalRow = { teamId: number; name: string; minute: number | null; offset: number; penalty: boolean; owngoal: boolean }
for (const g of rawGoals) {
if (!g.name) continue function collectGoals(teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number): GoalRow[] {
return rawGoals.flatMap(g => {
if (!g.name) return []
const minute = g.minute != null ? parseInt(String(g.minute)) : null const minute = g.minute != null ? parseInt(String(g.minute)) : null
const scoringTeamId = g.owngoal ? isOwnGoalTeamId : teamId return [{ teamId: g.owngoal ? isOwnGoalTeamId : teamId, name: g.name,
await db.execute(sql` minute: isNaN(minute!) ? null : minute, offset: g.offset ?? 0,
penalty: g.penalty ?? false, owngoal: g.owngoal ?? false }]
})
}
async function replaceGoals(matchId: number, rows: GoalRow[]) {
await db.transaction(async tx => {
await tx.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
if (rows.length > 0) {
// Single bulk INSERT — readers see old goals until commit, never an empty window
const vals = rows.map(g =>
sql`(${matchId}, ${g.teamId}, ${g.name}, ${g.minute}, ${g.offset}, ${g.penalty}, ${g.owngoal})`
)
await tx.execute(sql`
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal) INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES (${matchId}, ${scoringTeamId}, ${g.name}, ${isNaN(minute!) ? null : minute}, VALUES ${sql.join(vals, sql`, `)}
${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false})
`) `)
} }
})
} }
console.log('\nSyncing 2026...') console.log('\nSyncing 2026...')
@@ -153,9 +168,11 @@ async function run() {
const score = parseScore(m.score) const score = parseScore(m.score)
const matchId = await upsertMatch(2026, m.round ?? 'Unknown', m.group ?? null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, false) const matchId = await upsertMatch(2026, m.round ?? 'Unknown', m.group ?? null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, false)
if (m.goals1?.length || m.goals2?.length) { if (m.goals1?.length || m.goals2?.length) {
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`) const goalRows = [
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id) ...(m.goals1?.length ? collectGoals(t1Id, m.goals1, t2Id) : []),
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id) ...(m.goals2?.length ? collectGoals(t2Id, m.goals2, t1Id) : []),
]
await replaceGoals(matchId, goalRows)
} }
matchCount++ matchCount++
goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0) goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0)
@@ -188,8 +205,13 @@ async function run() {
const t2Id = await upsertTeam(m.team2) const t2Id = await upsertTeam(m.team2)
const score = parseScore(m.score) const score = parseScore(m.score)
const matchId = await upsertMatch(2026, m.round ?? 'Qualifier', null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, true) const matchId = await upsertMatch(2026, m.round ?? 'Qualifier', null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, true)
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id) if (m.goals1?.length || m.goals2?.length) {
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id) const goalRows = [
...(m.goals1?.length ? collectGoals(t1Id, m.goals1, t2Id) : []),
...(m.goals2?.length ? collectGoals(t2Id, m.goals2, t1Id) : []),
]
await replaceGoals(matchId, goalRows)
}
} }
console.log(` Quali playoffs: ${qualiData.matches.length} matches`) console.log(` Quali playoffs: ${qualiData.matches.length} matches`)
} }