SHA256
1
0

common: fix three bugs in the timeline cache

1. Double-check locking (TOCTOU)
   GetTimeline dropped the read lock, then acquired the write lock to
   fetch fresh data, but never re-checked the cache after the write lock
   was acquired.  A second goroutine could pass the stale-check under the
   read lock at the same time, resulting in both goroutines fetching the
   full timeline independently and overwriting each other's result.  Add
   a re-check immediately after acquiring the write lock so only the
   first writer fetches.

2. LastCachedTime used the wrong item
   The incremental fetch used data[0].Updated as the high-water mark for
   the Since filter, but data is sorted in descending Created order so
   data[0] is the most-recently-created entry, whose Updated timestamp
   may not be the latest across all cached items.  Switch to a linear
   scan over all items to find the true maximum Updated value, matching
   the fix in origin/fix/timeline-cache-race.

3. Cache not invalidated after RequestReviews / UnrequestReview
   After the bot called RequestReviews or UnrequestReview, the timeline
   cache for that PR was left hot for up to 5 seconds.  Any subsequent
   GetTimeline call within that window returned stale data that did not
   include the just-created review_requested / review_request_removed
   entry, causing FindMissingAndExtraReviewers to make decisions based on
   outdated reviewer state.  Call ResetTimelineCache on success in both
   methods so the next read always sees the mutation.
This commit is contained in:
2026-03-10 21:50:29 +01:00
parent 572e33111b
commit 8fa732e675

View File

@@ -768,6 +768,10 @@ func (gitea *GiteaTransport) RequestReviews(pr *models.PullRequest, reviewers ..
return nil, fmt.Errorf("Cannot create pull request reviews: %w", err)
}
// Invalidate the timeline cache so the next GetTimeline call reflects
// the newly created review_requested entry.
gitea.ResetTimelineCache(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
return review.GetPayload(), nil
}
@@ -776,6 +780,13 @@ func (gitea *GiteaTransport) UnrequestReview(org, repo string, id int64, reviwer
repository.NewRepoDeletePullReviewRequestsParams().WithOwner(org).WithRepo(repo).WithIndex(id).WithBody(&models.PullReviewRequestOptions{
Reviewers: reviwers,
}), gitea.transport.DefaultAuthentication)
if err == nil {
// Invalidate the timeline cache so the next GetTimeline call reflects
// the newly created review_request_removed entry.
gitea.ResetTimelineCache(org, repo, id)
}
return err
}
@@ -861,24 +872,31 @@ func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models
prID := fmt.Sprintf("%s/%s!%d", org, repo, idx)
giteaTimelineCacheMutex.RLock()
TimelineCache, IsCached := giteaTimelineCache[prID]
var LastCachedTime strfmt.DateTime
if IsCached {
l := len(TimelineCache.data)
if l > 0 {
LastCachedTime = TimelineCache.data[0].Updated
}
// cache data for 5 seconds
if TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
giteaTimelineCacheMutex.RUnlock()
return TimelineCache.data, nil
}
if IsCached && TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
giteaTimelineCacheMutex.RUnlock()
return TimelineCache.data, nil
}
giteaTimelineCacheMutex.RUnlock()
giteaTimelineCacheMutex.Lock()
defer giteaTimelineCacheMutex.Unlock()
// Re-read after acquiring the write lock: another goroutine may have
// already refreshed the cache while we were waiting.
TimelineCache, IsCached = giteaTimelineCache[prID]
if IsCached && TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
return TimelineCache.data, nil
}
// Find the highest Updated timestamp across all cached items so the
// incremental fetch picks up both new entries and modified ones.
var LastCachedTime strfmt.DateTime
for _, d := range TimelineCache.data {
if time.Time(d.Updated).Compare(time.Time(LastCachedTime)) > 0 {
LastCachedTime = d.Updated
}
}
for resCount > 0 {
opts := issue.NewIssueGetCommentsAndTimelineParams().WithOwner(org).WithRepo(repo).WithIndex(idx).WithPage(&page)
if !LastCachedTime.IsZero() {