Access tokens and Knex pool exhaustion: why last_used_at updates on every request
#5135
Replies: 1 comment
|
Nothing in the current One thing worth changing in the implementation, though: your sketch does a SELECT, checks the interval in JS, then conditionally UPDATEs, that's still two round trips on the common "needs update" path and a wasted branch check on every request either way. Pushing the interval check into the UPDATE's WHERE clause makes the skip atomic and removes the JS-side branching entirely: UPDATE access_tokens
SET last_used_at = now()
WHERE id = ? AND type = ?
AND (last_used_at IS NULL OR last_used_at < now() - interval '15 minutes')Now every verify still does exactly one UPDATE statement, but it's a no-op write (0 rows affected) on the throttled path instead of a full row write, and you don't need The core feature request is reasonable on its own merits, but I'd frame the ask around the WHERE-clause version rather than an interval config knob, it gets you the same behavior with less surface area for the maintainers to review, and it's a smaller diff against the existing |
Uh oh!
There was an error while loading. Please reload this page.
Short version
If you use
@adonisjs/authaccess tokens under high authenticated QPS, you may see:even when PostgreSQL CPU and connection count look fine.
One quiet amplifier:
DbAccessTokensProvider.verify()alwaysUPDATEslast_used_aton every authenticated request. There is no built-in way to throttle or disable that write. On a busy API that can mean one extra write per request, which holds Knex pool slots and crowds out everything else — including login.We mitigated by subclassing the provider and only updating
last_used_atevery N minutes. A first-class option in core would help a lot of production apps.What we saw
Symptoms looked like “the database is dying”:
POST /auth/signinacquireTimeoutMillis(e.g. 5s)KnexTimeoutErrorrotating across random “culprits” (AuthMiddleware, sign-in, random services)But infra contradicted that story:
So this was not “Postgres is out of capacity.” It was per-process Knex pool starvation: every Node process has a small pool (e.g.
max: 10–20). When too many concurrent requests each hold a connection — especially on short writes — new acquires time out after a few seconds.Classic red herring: the error message asks about missing
.transacting(trx). Sometimes that is the cause. Often it is just “the pool is busy.”The Adonis access-token path
With the access tokens guard, every authenticated request roughly does:
verify(token)→ SELECT token rowlast_used_at = now()In
@adonisjs/auth(access tokens provider), the update is unconditional:That is a reasonable default for audit / “last active” / idle cleanup. Under load it becomes expensive:
last_used_ataloneMobile apps that poll dashboard / “me” endpoints make this especially painful: the same token is verified many times per minute, and almost every verify is a write.
Meanwhile unauthenticated login only needs a single
User.findBy(...). If the process pool is already full of auth middleware work, login fails with a pool timeout — which feels like “auth is broken” when the real issue is connection slot contention.What did not fix it (alone)
max_connectionsfirstDB_POOL_MAX(helps briefly; can increase pressure on the proxy/DB)What did help as relief:
Mitigation: throttle
last_used_atSubclass
DbAccessTokensProvider, overrideverify(), and skip the UPDATE whenlast_used_atis recent (e.g. 15 minutes). Keep SELECT + hash/expiry checks every time.Sketch:
Trade-off:
last_used_atbecomes “last used within ~15 minutes,” not “exact last request.” For most products that is fine. Security (hash + expiry) is unchanged.Also audit your own middleware for accidental full-table scans or N+1 on every request — those hold pool slots even longer than a single
last_used_atupdate.Ask for core: make this configurable
It would be great if
@adonisjs/authsupported something like:Or a hook / strategy:
Defaults can stay as today (update every verify) for backward compatibility. Production APIs with high authenticated QPS could opt into throttling without forking
verify().Happy to open an issue / PR against
adonisjs/authif maintainers are open to the idea.Checklist if you hit
KnexTimeoutErrorpool.maxandacquireTimeoutMillisper process.Closing
Access tokens that touch
last_used_aton every request are a footgun at scale, not a bug in Adonis — but the lack of a throttle option makes the footgun hard to avoid without subclassing. If this resonates, a small core knob would save a lot of production debugging.Feedback welcome — especially from folks who solved this differently (cache layer, JWT, etc.).
All reactions