mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-07-19 04:07:37 -05:00
[PR #296] [MERGED] fix: prevent duplicate issues on retry-after-deadlock + pre-fetch pagination #5968
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/RayLabsHQ/gitea-mirror/pull/296
Author: @seanmousseau
Created: 5/22/2026
Status: ✅ Merged
Merged: 5/23/2026
Merged by: @arunavo4
Base:
main← Head:fix/dedup-create-on-deadlock-retry📝 Commits (1)
b76073bfix: prevent duplicate issues & PRs on retry-after-deadlock + Link-header pagination📊 Changes
2 files changed (+446 additions, -28 deletions)
View changed files
➕
src/lib/gitea-issue-dedup-on-retry.test.ts(+282 -0)📝
src/lib/gitea.ts(+164 -28)📄 Description
Summary
Fixes two compounding bugs in
mirrorGitRepoIssuesToGiteaandmirrorGitRepoPullRequestsToGiteathat produce duplicate Gitea issue/PR-as-issue rows on every sync against any non-SQLite backend. In a real-world test (Gitea 1.26.2 on PostgreSQL 17.10), an unpatched sync produced +1,878 dups in 15 minutes during initial repro and +3,345 dups overnight with hourly cron firing the bug repeatedly. With the patch, the same workload produces 0 new duplicates.Bug #1 — pagination on existing-issues / existing-PRs / per-issue-comments
Three paginated pre-fetches in
gitea.tspaginated withlimit=100and broke onpageX.length < itemsPerPage. Gitea caps response size at server-side[api].MAX_RESPONSE_ITEMS(default 50), so the very first page already looks "short" and pagination terminated after one page. The existing-issue, existing-PR, and existing-comment maps were built from only ~50 items per repo, so every entry past page 1 was misclassified as new on every sync and re-created via the CREATE branch.Naive removal of the short-page break doesn't work either: for some Gitea endpoints (e.g. issue comments past the actual end) Gitea returns the same data on every page instead of returning
[], so the loop runs forever.Fix: use the
Linkheader (RFC 5988). Ifrel="next"is absent in the response, terminate pagination. Applied to: existing-issues pre-fetch, existing-PRs pre-fetch, and per-issue comments fetch. The empty-page check is kept as a defensive early-out.Bug #2 — retry-after-deadlock duplicates issues and PRs
Even when the dedup map is complete, a second class of duplication fires when Gitea's
CreateIssuehandler commits the issue insert in one transaction and then deadlocks on the subsequentaddLabel/UPDATE repositorytransaction. The row is committed and visible, but the in-memory dedup map is never refreshed between retries.processWithRetry(defaultmaxRetries: 2) re-invokes the per-item callback, seesexistingItem === undefinedfrom the stale map, and creates a fresh duplicate viahttpPost.Reproduces deterministically on MySQL (
Error 1213 (40001)) and PostgreSQL (pq: deadlock detected (40P01)). SQLite escapes because writes serialize globally.Fix: two guards inside each create branch:
[GH-ISSUE #N](issues) or[PR #N](PRs) beforehttpPost. If found, switch to PATCH and cache the hit.httpPost.Applied to: the issues create path, the PR mirror's enriched create path, and the PR mirror's basic-fallback create path. Logs a
Recovered orphan from prior failed attempt for #Nline so operators can spot recovery in practice.Real-world evidence
Same test environment, same gitea-mirror cron schedule:
With the pagination fix the pre-fetch correctly enumerates all existing items via
Link: rel="next". Every GitHub iteration finds its match → routes to PATCH → nohttpPost→ no addLabel deadlock → no retry → no orphan to recover. The recheck-before-create is a defense-in-depth layer for genuinely-new items whose first create attempt deadlocks; on a re-sync it's essentially never invoked.Tests
Adds
src/lib/gitea-issue-dedup-on-retry.test.tsusing the structural-source test pattern fromgitea-mirror-failure-recovery.test.ts(issue #268's regression test). 8 assertions covering:httpPostcall still existshttpGetusing[GH-ISSUE #N]runs before the createLinkheader (rel=\"next\")LinkheaderRecovered orphanlog lines (one each for the enriched + basic-fallback create paths)bun test— 255 pass / 4 skip / 0 fail (up from 247 baseline).Related issues
Caveat / honest disclosure
The test sync occasionally stalls partway through — sync produces some successful
Mirrored issuelog lines then goes quiet, while the job row sits atin_progress=true, last_checkpoint=neverand the recovery detector logs "Found 1 interrupted jobs" repeatedly without ever invoking the recovery handler. This stall is independent of this PR's changes — reproduces identically on stockghcr.io/raylabshq/gitea-mirror:latest, matches reports in #268's open comments, and lives entirely in orchestration code (helpers.ts,recovery.ts,scheduler-service.ts) that this PR does not touch. On the items that do process, the dedup guarantee in this PR holds: zero new duplicates. Worth a follow-up.Test plan
bun install && bun test— all green (255/4/0)docker buildx build --platform linux/amd64succeeds🤖 Generated with Claude Code
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.