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
+38 -16
View File
@@ -37,7 +37,7 @@ function parseScore(score: RawScore | undefined) {
}
async function run() {
const client = postgres(DATABASE_URL!, { max: 5 })
const client = postgres(DATABASE_URL!, { max: 2 })
const db = drizzle(client)
const teamCache = new Map<string, number>()
@@ -94,17 +94,32 @@ async function run() {
return (rows[0] as { id: number }).id
}
async function syncGoals(matchId: number, teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number) {
for (const g of rawGoals) {
if (!g.name) continue
type GoalRow = { teamId: number; name: string; minute: number | null; offset: number; penalty: boolean; owngoal: boolean }
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 scoringTeamId = g.owngoal ? isOwnGoalTeamId : teamId
await db.execute(sql`
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},
${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false})
`)
}
return [{ teamId: g.owngoal ? isOwnGoalTeamId : teamId, name: g.name,
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)
VALUES ${sql.join(vals, sql`, `)}
`)
}
})
}
console.log('\nSyncing 2026...')
@@ -153,9 +168,11 @@ async function run() {
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)
if (m.goals1?.length || m.goals2?.length) {
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id)
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)
}
matchCount++
goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0)
@@ -188,8 +205,13 @@ async function run() {
const t2Id = await upsertTeam(m.team2)
const score = parseScore(m.score)
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.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id)
if (m.goals1?.length || m.goals2?.length) {
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`)
}