perf(auth): debounce access token "last used" writes - #39175
Open
Jason4869 wants to merge 1 commit into
Open
Conversation
Every successful token authentication performs a full-row UPDATE of the
token's own `access_token` row purely to advance `updated_unix`
("last used"). `UpdateAccessToken` uses `AllCols()`, so all eight columns
are rewritten, and it is called synchronously in the request path from
both `services/auth/basic.go` (PAT via Basic/Bearer/`?token=`) and
`services/auth/oauth2.go` (legacy non-JWT SHA tokens), with no condition,
debounce or sampling on either path.
The consequence is that every concurrent request presenting the *same*
token serialises on that one row's exclusive lock. While the database is
healthy the lock is held for well under a millisecond and this is
invisible, but the per-row ceiling degrades in proportion to database
latency: once it falls below the request arrival rate the queue grows
without bound, so a modest database slowdown turns into a full
serialisation collapse rather than proportional degradation.
Add a staleness guard and wrap both call sites in it:
const AccessTokenUseInterval = 30 * time.Second
func ShouldPersistTokenUse(last timeutil.TimeStamp, now time.Time) bool
This mirrors `RunnerHeartbeatInterval` / `ShouldPersistLastOnline`, added
in go-gitea#38281 for the equivalent per-poll write on `action_runner.last_online`
— same naming, same 30s interval, same call-site guard placement.
`updated_unix` is display-only: it feeds the "last used" column in the UI
and the 7-day `HasRecentActivity` flag set in `AfterLoad()`. It is never
consulted for an authorization decision, so bounding its freshness to 30s
is not security-relevant; the worst observable effect is a token's
"last used" reading up to 30s stale.
The guard is also safe across multiple Gitea processes:
`GetAccessTokenBySHA` re-reads the row from the database on every call —
even on a token-cache hit, where the cache stores only the row ID and the
row itself is re-fetched — so the `UpdatedUnix` being compared is always
the committed value, never a per-process cached one.
Token validation itself is unchanged; only the write is skipped. Lookup,
hash comparison, scope and user resolution are untouched.
Signed-off-by: Jason Lowe <jason4869@gmail.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The change is minimal and pattern-consistent, reduces synchronous DB writes on hot auth paths, and includes targeted unit coverage for the new guard.
Pull request overview
This PR reduces database row-lock contention caused by updating access_token.updated_unix (“last used”) on every successful token-authenticated request by adding a 30s staleness guard, mirroring the existing debounce pattern used for Actions runner heartbeats.
Changes:
- Add
AccessTokenUseIntervalandShouldPersistTokenUseto debounce “last used” persistence for access tokens. - Guard the synchronous
UpdateAccessTokenwrite in both token-auth call paths (basicPAT auth and legacy SHA token OAuth2 path). - Add unit coverage for the new staleness guard.
File summaries
| File | Description |
|---|---|
| services/auth/oauth2.go | Wraps access-token “last used” persistence with ShouldPersistTokenUse to avoid per-request row updates. |
| services/auth/basic.go | Applies the same debounce guard to PAT authentication and adds the needed time import. |
| models/auth/access_token.go | Introduces AccessTokenUseInterval and ShouldPersistTokenUse (pattern-aligned with Actions runner debounce). |
| models/auth/access_token_test.go | Adds table-driven tests for ShouldPersistTokenUse. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
lunny
reviewed
Aug 31, 2026
| } | ||
|
|
||
| t.UpdatedUnix = timeutil.TimeStampNow() | ||
| if err = auth_model.UpdateAccessToken(ctx, t); err != nil { |
Member
There was a problem hiding this comment.
And it seems only the updated time should be updated, the function UpdateAccessToken could be rewrite to not update other columns.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #39174.
Every successful token authentication performs a full-row
UPDATEof the token's ownaccess_tokenrow, purely to advanceupdated_unix("last used").UpdateAccessTokenusesAllCols(), so all eight columns are rewritten, and it is called synchronously in the request path fromservices/auth/basic.go(PAT via Basic/Bearer/?token=) andservices/auth/oauth2.go(legacy non-JWT SHA tokens), with no condition, debounce or sampling on either path.The consequence is that every concurrent request presenting the same token serialises on that one row's exclusive lock. While the database is healthy the lock is held for well under a millisecond and this is invisible, but the per-row ceiling degrades in proportion to database latency: once it falls below the request arrival rate the queue grows without bound, so a modest database slowdown turns into a full serialisation collapse rather than proportional degradation. #39174 has the measurements and the incident this caused on our instance.
The change
A staleness guard, and both call sites wrapped in it:
This is a deliberate mirror of
RunnerHeartbeatInterval/ShouldPersistLastOnline, added in #38281 for the equivalent per-poll write onaction_runner.last_online— same naming, same 30s interval, same call-site guard placement. The intent is that this reads as an extension of a pattern already accepted here rather than a novel proposal; if you would prefer a different interval, a shared constant across both tables, or the guard pushed down intoUpdateAccessTokenitself, I am happy to rework it.Why this is safe
updated_unixis display-only. It feeds the "last used" column in the UI and the 7-dayHasRecentActivityflag set inAfterLoad(). It is never consulted for an authorization decision, so bounding its freshness to 30s is not security-relevant. The worst observable effect is a token's "last used" reading up to 30s stale — well inside the 7-day window, which is the only thing besides display that reads the column.GetAccessTokenBySHAre-reads the row from the database on every call — even on a token-cache hit, where the cache stores only the row ID and the row itself is re-fetched viadb.GetEngine(ctx).ID(cached.TokenID).Get(accessToken). So theUpdatedUnixthe guard compares against is always the committed value, never a per-process cached one.UpdateAccessTokenunguarded, and the column carries xorm'supdatedtag, so those writes still advance the timestamp as before.Testing
TestShouldPersistTokenUseadded inmodels/auth/access_token_test.go, table-driven in the same shape as the existingTestShouldPersistLastOnline(fresh / exactly-at-interval / stale / never-used).Full tree builds clean;
gofmtclean on all four changed files.