From 6ea46d08b37d0e89df677edd34d6f9febf2f375d Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 02:30:21 -0400 Subject: [PATCH] =?UTF-8?q?fix(sync):=20upsertIssueRecord=20is=20an=20atom?= =?UTF-8?q?ic=20INSERT=20ON=20CONFLICT=20=E2=80=94=20closes=20the=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-then-save let two concurrent syncs both find no row and both INSERT, so one hit the unique githubIssueId constraint. The atomic INSERT ... ON CONFLICT DO UPDATE makes the write race-safe; the stale-check above still guards the update path. Resolves #562 --- src/github/github-sync.service.ts | 40 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/github/github-sync.service.ts b/src/github/github-sync.service.ts index 637795e3..af0c83c9 100644 --- a/src/github/github-sync.service.ts +++ b/src/github/github-sync.service.ts @@ -8,10 +8,7 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { Octokit } from '@octokit/rest'; -import { - FindOptionsRelations, - Repository as TypeOrmRepository, -} from 'typeorm'; +import { FindOptionsRelations, Repository as TypeOrmRepository } from 'typeorm'; import { ANALYTICS_PLATFORM_INVALIDATE_EVENT } from '../analytics/analytics.events'; import { Issue, Repository } from '../common/entities'; import { IssueState } from '../common/enums'; @@ -305,10 +302,37 @@ export class GithubSyncService { githubUpdatedAt: incomingUpdatedAt, }; - const issue = existing - ? this.issueRepo.merge(existing, attrs) - : this.issueRepo.create(attrs); - return { issue: await this.issueRepo.save(issue), applied: true }; + // Atomic INSERT ... ON CONFLICT DO UPDATE — the prior read-then-save let + // two concurrent syncs both find no row and both INSERT, so one hit the + // unique githubIssueId constraint (#562, regression of #376). The atomic + // upsert makes the write race-safe; the stale-check above still guards + // the update path, and the conflict-update is conditional on the incoming + // row being newer so a stale delivery can't overwrite fresh data. + const githubId = githubIssueId; + await this.issueRepo + .createQueryBuilder() + .insert() + .into(Issue) + .values(attrs as never) + .orUpdate( + [ + 'number', + 'title', + 'body', + 'state', + 'labels', + 'githubUrl', + 'authorLogin', + 'isMaintenanceType', + 'closedAt', + 'githubUpdatedAt', + 'repositoryId', + ], + ['githubIssueId'], + ) + .execute(); + const issue = await this.findIssueByGithubIdOrNull(githubId); + return { issue: issue as Issue, applied: true }; } async findRepositoryByGithubId(