fix(session): repopulate secondaryStorage after database fallback in findSession - #11230
fix(session): repopulate secondaryStorage after database fallback in findSession#11230breken-ai wants to merge 4 commits into
Conversation
|
@swetabhsmn is attempting to deploy a commit to the better-auth Team on Vercel. A member of the Team first needs to authorize it. |
|
There was a problem hiding this comment.
2 issues found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/better-auth/src/db/internal-adapter.ts">
<violation number="1" location="packages/better-auth/src/db/internal-adapter.ts:678">
P1: When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise `listSessions` and revoke-all can miss a still-cached session until its TTL expires.</violation>
<violation number="2" location="packages/better-auth/src/db/internal-adapter.ts:686">
P1: When revoke-all races a database fallback, the fallback can write the token after `deleteUserSessions` commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| const sessionTTL = getTTLSeconds(parsedSession.expiresAt, now); | ||
| if (sessionTTL > 0) { | ||
| const activeSessionsKey = `active-sessions-${parsedUser.id}`; | ||
| const currentList = await secondaryStorage.get(activeSessionsKey); |
There was a problem hiding this comment.
P1: When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise listSessions and revoke-all can miss a still-cached session until its TTL expires.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/db/internal-adapter.ts, line 678:
<comment>When two sessions for the same user miss secondary storage concurrently, both repairs read the same active-sessions list and the later write drops the other token. Serialize or atomically merge index repairs; otherwise `listSessions` and revoke-all can miss a still-cached session until its TTL expires.</comment>
<file context>
@@ -663,6 +663,48 @@ export const createInternalAdapter = (
+ const sessionTTL = getTTLSeconds(parsedSession.expiresAt, now);
+ if (sessionTTL > 0) {
+ const activeSessionsKey = `active-sessions-${parsedUser.id}`;
+ const currentList = await secondaryStorage.get(activeSessionsKey);
+ const list =
+ safeJSONParse<{ token: string; expiresAt: number }[]>(currentList) ||
</file context>
There was a problem hiding this comment.
Valid, and the same family as the revoke race in the greptile thread above: fully closing it needs atomic coordination between the cache index and the database (a Lua-merged index write on Redis, or versioned entries), which is a design change beyond this fix - it is disclosed as a known limitation in the PR body rather than half-guarded. The practical exposure is bounded: both repairs write the same session back, so the lost update only drops a sibling token from the active-sessions index (listSessions / revoke-all can miss that still-valid session until its TTL), while the dropped session's own key is repopulated on its next request. Happy to take the atomic design as a follow-up if you want it.
| (s) => s.expiresAt > now && s.token !== token, | ||
| ); | ||
| filtered.push({ | ||
| token, |
There was a problem hiding this comment.
P1: When revoke-all races a database fallback, the fallback can write the token after deleteUserSessions commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/db/internal-adapter.ts, line 686:
<comment>When revoke-all races a database fallback, the fallback can write the token after `deleteUserSessions` commits and its queued cache sweep runs. Subsequent cache hits then keep the revoked token usable until TTL expiry; coordinate write-back with revocation or revalidate before publishing the cache entry.</comment>
<file context>
@@ -663,6 +663,48 @@ export const createInternalAdapter = (
+ (s) => s.expiresAt > now && s.token !== token,
+ );
+ filtered.push({
+ token,
+ expiresAt: parsedSession.expiresAt.getTime(),
+ });
</file context>
There was a problem hiding this comment.
Same answer as the greptile thread above: agreed the write-back can publish after a revoke-all sweep, and we deliberately did not ship a re-read half-guard - it shrinks the window without closing it and reads as closed in review. Fully closing it needs atomic cache/database coordination, disclosed as a known limitation in the PR body. Bounded in practice: requires storeSessionInDatabase with a live DB row, a concurrent revoke-all in the exact interleaving, and the resurrected entry carries only its remaining session TTL. Happy to pick the atomic design up as a follow-up.
Addressing review feedback on better-auth#11230: the newly awaited repair writes meant a secondary-storage failure (eviction + unreachable Redis) could reject out of findSession after the database had already returned a valid session, turning /get-session into an internal error. The whole repair block is now try/wrapped and logged, matching the existing deferred-mirror error posture. Known limitation (disclosed, following review discussion): the repair still has a theoretical check-then-act window against a CONCURRENT revoke (revoke deletes the database row in flight while this repair repopulates the cache, so the token could remain accepted until its session TTL expires). Fully closing that window requires atomic cache+database primitives (e.g. a Lua-scripted revoke on Redis or versioned tokens), which is beyond the scope of this repair fix. Added a best-effort test: a throwing secondary storage cannot break the database fallback on /get-session.
| const currentList = | ||
| await secondaryStorage.get(activeSessionsKey); | ||
| const list = | ||
| safeJSONParse<{ token: string; expiresAt: number }[]>( | ||
| currentList, | ||
| ) || []; | ||
| const filtered = list.filter( | ||
| (s) => s.expiresAt > now && s.token !== token, | ||
| ); | ||
| filtered.push({ | ||
| token, | ||
| expiresAt: parsedSession.expiresAt.getTime(), | ||
| }); | ||
| filtered.sort((a, b) => a.expiresAt - b.expiresAt); | ||
| const furthestSessionTTL = getTTLSeconds( | ||
| filtered[filtered.length - 1]!.expiresAt, | ||
| now, | ||
| ); | ||
| await Promise.all([ | ||
| secondaryStorage.set( | ||
| token, | ||
| JSON.stringify({ session: parsedSession, user: parsedUser }), | ||
| sessionTTL, | ||
| ), | ||
| secondaryStorage.set( | ||
| activeSessionsKey, | ||
| JSON.stringify(filtered), | ||
| furthestSessionTTL, | ||
| ), | ||
| ]); |
There was a problem hiding this comment.
Concurrent repairs lose sessions
When two sessions for the same user concurrently fall back to the database, both repairs can read the same active-session index and then overwrite it with only their own token. The losing token remains cached but disappears from the index, so revoke-all deletes its database row without deleting the cached session. Later cache-hit lookups continue authenticating that session until its TTL expires.
How this was verified: Revocation deletes cached tokens solely through the active-session index, while cache-hit session lookup accepts the cached value without rechecking the database.
Knowledge Base Used: Distributed storage and caching
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 2 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Fix all with cubic | Re-trigger cubic
bytaesu
left a comment
There was a problem hiding this comment.
Check the comments, follow up where needed, and resolve them if there are no issues.
The best-effort hardening covered the cache-repair writes, but the initial secondaryStorage.get(token) at the top of findSession was still unguarded: a storage outage rejected the request before the database fallback could run. Treat a failed read as a cache miss (logged) and fall through to the database; without storeSessionInDatabase the miss path still returns null as before. Also restructure the outage test so it can reach the path it covers: session creation touches the cache synchronously, so sign-in must run against the healthy store; the outage now flips on only for the read path.
|
@bytaesu Follow-up done across all review threads, per your request: Addressed in e40449c (pushed just now):
Answered with rationale (no code change): the two cubic P1s on repair-write races (concurrent repairs lost-update on the index; revoke-all racing the write-back) and greptile's earlier revoke-restore P1. All three need atomic cache/database coordination (Lua-merged writes or versioned entries) - a design change beyond this fix, disclosed in the PR body's known-limitation note, with the exposure bounds in each reply. Happy to pick that design up as a follow-up. Already marked addressed by the bot: cubic's P2 on the non-array Two honesty notes: I don't have permission to resolve conversations on this repo, so each thread carries a reply instead - feel free to resolve as you re-review. And we could not run vitest locally from this environment; the test restructure is verified by static trace, CI runs the suite. |
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
e40449c guarded the initial secondary-storage read unconditionally: with storeSessionInDatabase disabled (the default) a failed read fell through to return null, so a transient outage was treated as an invalid session and /get-session could clear valid cookies. Only treat a read failure as a cache miss when the database fallback can actually serve the session (storeSessionInDatabase enabled and preserveSessionInDatabase off); otherwise re-throw so the outage surfaces as a request error with the session cookie left intact. Adds a test for the no-database-fallback outage case. Not run locally (no dependency install in this environment); vitest secondary-storage suite should cover both outage modes.
Fixes #11218.
When a session was missing from secondaryStorage (TTL expiry, eviction, flush) but still valid in the database, findSession's database fallback returned it without writing it back - so every subsequent request for that session kept hitting the primary database until the next login.
The fallback now repopulates secondaryStorage with the remaining TTL (same shape as createSession's mirror). It also restores the token in the active-sessions index: deleteUserSessions sweeps cached sessions through that index, so a write-back without it would have let a repopulated session survive revoke-all until cache expiry. The existing preserveSessionInDatabase early return is untouched, so revoked-and-preserved rows still can't be restored.
Tests: two cases in secondary-storage.test.ts - cache/index repair after a simulated flush, and revoke-all still sweeping a repopulated session. Both fail on main; the full file plus internal-adapter and session-api suites pass with the fix.
Summary by cubic
Fixes #11218. When a session is missing from secondary storage (TTL expiry, eviction, flush) but still valid in the database,
findSessionnow writes it back with its remaining TTL and restores the active-sessions index entry, so repeat reads no longer hit the primary DB and revoke-all still sweeps the session.storeSessionInDatabase, a secondary-storage outage surfaces as a request error instead of being treated as an invalid session that could clear valid cookies.preserveSessionInDatabaseearly return is unchanged, so revoked-and-preserved rows cannot be resurrected.Written for commit f34ee67. Summary will update on new commits.
Follow-up (review feedback, 2026-09-09):
findSessionafter the database has already returned the authoritative session - it logs the error and serves the DB result. Matching test: a throwing secondary storage cannot turn/get-sessioninto a 500.