-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: JWT cache implementation based on sieve algorithm #4084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
This PR contains:
|
Very interesting! 👀 👀
Maybe we could expose those as metrics. That would help with passing code coverage too, since it looks like it's detecting those functions as dead code:
I also see the JWT loadtest failing on CI https://github.com/PostgREST/postgrest/actions/runs/15030111955?pr=4084:
Not sure what's going on there, but you should be able to reproduce it locally with |
Not sure what the problem is - tried to find typos in my changes to env variables settings, main (0b3c8c9) has the same issue on my machine. |
Weird, it's like you're using and old version of the nix tools and it's ignoring the $ postgrest-loadtest -k jwt
Created 50000 targets in ./test/load/gen_targets.http (0.79s)
...
## this is the ouput you're getting
$ postgrest-loadtest -k mixed # same as just "postgrest-loadtest"
delaying data to/from postgres by 0ms Maybe try going out of |
Yeah, that was it, thanks. I have serious doubts about Nevertheless there indeed seems to be an issue with implementation in this PR: all percentiles (ie. median up to 99th) show better results than |
Agree, we can change that.
Note that the jwt loadtest actually generates unique JWTs , it was mainly done to test the jwt decoding perf while also ensuring the JWT cache purging doesn't slow things down (#4034). The "mixed" loadtest does have hardcoded JWTs, and the perf looks more or less maintained (or slightly better). |
@steve-chavez @wolfgangwalther @taimoorzaeem I am not sure what to do with outstanding test coverage issues. This is about The question is: how to handle invalid JWTs? There are several options:
I've decided option three makes sense as it also speeds up repeated invalid token requests. OTOH - it opens up possibility of cache filling attacks using randomly generated tokens. It is disputable if that is a bigger problem than overloading CPU with eg. JWTs signed with random key. It looks to me option 4 would be the best compromise because it would prevent filling up cache with random garbage but would offload signature validation. I haven't implemented it as I don't know how to split parsing and signature validation with JOSE. WDYT? |
Parsing a JWT is really simple. Split on The parser that is used for But do we really need to? Parsing a JWT is really simple - but creating parseable tokens, with invalid signatures is just as easy. So I don't really see the additional value of option 4 vs option 3. If somebody wants to fill the cache, they can do so easily. |
Done. Three new counters are provided:
|
Indeed - any random bytes are a signature that consumes CPU to validate. So that leaves us with the choice between caching negative results or caching only valid JWTs. I've made cache implementation polymorphic over value computation monad so it is easy to change strategies. The latest commits introduce possibility of selecting one of two variants in PostgREST.Auth.JwtCache - it is a matter of changing |
I wonder whether we can cache all valid JWTs, but including expired ones? Aka when validation fails because of expiry, still cache. When validation fails because of something else, don't. The assumption is, that:
|
That’s exactly option 2. Which is currently implemented as notCachingErrors variant (switchable in JwtCache.init). As described in the description of the PR (first comment) - we don’t cache AuthResults but raw parsed claims that are re-validated for each request. |
Well, not exactly, but close enough I agree. We'd still cache those that fail, for example, and audience check or so. But that's totally fine, yes. |
Right - it is not exactly the same. The reason I decided to leave claims checking until after cache lookup are two-fold:
And, of course, claims checking is very fast, so there is little sense in caching it. |
Yes, this makese a lot of sense! So the only change that requires a reset is the change of secret, right? |
That, and - of course - turning off caching alltogether (ie. setting |
Changes: 1. Refactoring and some cleanup of JWT handling code: * Moved JWT parsing and validation to a separate module Auth.JWT * Split JWT decoding, parsing and signature validation, claims validation into separate functions * Instead of caching AuthResult cache decoded claims (which signature was verified). Validating claims and determining role is done after cache lookup * Cleaned up API so that usage of it is simplified: lookupJwtCache cache key >>= parseClaims configJwtAud time * Handling of JwtCacheState initialization and updates of configuration is encapsulated in Auth.JwtCache module 2. Generic high performance (hopefully) scalable, dynamically resizeable cache implementation based on stm, stm-hamt and sieve algorithm. It also integrates with PostgREST measurements infrastructure providing usage stats (ie. hit ratio, evictions count)
@steve-chavez @wolfgangwalther @taimoorzaeem After some more work on this PR I think it is now in a mergeable state (pending documentation changes, changelog adjustments etc. - and of course code review). I am pretty confident it is working fine as I've added JWT cache behavior tests that verify hits/misses and evictions using metrics. Please, let me know if there are any adjustments / changes required (or if you think the whole idea is wrong). |
src/PostgREST/Config.hs
Outdated
-- <*> (fromMaybe 0 <$> optInt "jwt-cache-max-lifetime") | ||
<*> (fromMaybe 0 <$> optInt "jwt-cache-max-size") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the size
in there seems bit confusing as it may be confused with size in memory. How about renaming the config to jwt-cache-max-entries
? It seems more appropriate, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the
size
in there seems bit confusing as it may be confused with size in memory. How about renaming the config tojwt-cache-max-entries
? It seems more appropriate, no?
Fine for me. @steve-chavez WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, agree with jwt-cache-max-entries
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looked at other implementations, looks like "size" is used to mean max entries:
Max Entries in JWT Cache - org.forgerock.agents.jwt.cache.size
Not to say that we cannot be explicit in our config name. As an alternative, we could also do jwt-cache-max-length
or maybe just jwt-cache-max
?
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe1"}|] | ||
|
||
expectCounters | ||
[ | ||
requests (+ 1) | ||
, hits (+ 0) | ||
] $ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really elegant way to test the metrics 💯 So easy to read!
request methodGet "/authors_only" [jwt1] "" | ||
*> request methodGet "/authors_only" [jwt2] "" | ||
-- this one should hit the cache | ||
*> request methodGet "/authors_only" [jwt1] "" | ||
-- this one should trigger eviction of jwt2 (not FIFO) | ||
*> request methodGet "/authors_only" [jwt3] "" | ||
-- these two should hit the cache | ||
*> request methodGet "/authors_only" [jwt1] "" | ||
*> request methodGet "/authors_only" [jwt3] "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An idea to better test the contents of the JWT cache, would be to add a /jwtcache
endpoint to the Admin Server. That could print all the cached JWTs in order, possiblty decoded.
Not to say it should be done in this PR, can be separate.
|## Enables JWT Cache and sets its max size, disables caching with 0 | ||
|# jwt-cache-max-size = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should make the default 1000. That means the cache will be enabled by default for next major.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1000 is a rough estimation, mentioned before on #3802 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should make the default 1000. That means the cache will be enabled by default for next major.
, stm >= 2.5 && < 3 | ||
, stm-hamt >= 1.2 && < 2 | ||
, focus >= 1.0 && < 2 | ||
, some >= 1.0.4.1 && < 2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any concerns with the runtime overhead of some?
However, due to GHC issue #1965, the direct implementation of this datastructure is less efficient than it could be. As a result, this library uses a more complex approach that implements it as a newtype, so there's no runtime overhead associated with wrapping and unwrapping Some values.
https://github.com/haskellari/some
Is the dependency really needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation is based on doubly linked list: each node has two TVars pointing to previous and next node. To simplify handling of empty cache there is a single empty "head" node that has a different type than "entry" nodes that are put in the hash map.
Both node types are defined as two constructors of a GADT along the lines of:
data ListNode (k :: Bool) where
Head :: Node False
Entry :: Node True
(The above is a simplification to illustrate the point)
So the TVars point either to an "entry" node or to "head" - to do that we need to use existentials and hence the TVars are of type TVar (Some ListNode)
.
We could define our own data type data OurSome f = forall (k :: Bool). OurSome (f k)
but I found this library that simplifies this, provides some helpers and promises to use a newtype
instead.
@mkleczek Awesome! How about a gauge for number of cached JWTs? I'm currently load testing the feature and that would help me ensure the cache size is maxed and that it goes down. This might be good for tests too? I noticed that the previous JWT purge had a memory usage problem (#3889 (comment)) and this is now gone 🚀 I've recorded a video using postgrest-benchmark's OPTIONSUniqueJWT.js (this has the same logic as our Screencast.from.05-27-2025.09.56.23.PM.webmSharing the run results here for completeness:
Also the metrics after the run (counters are high because I did some previous runs):
@mkleczek I've noticed that the jwt loadtest results show a perf drop compared to main and latest version. Is that expected? |
Co-authored-by: Steve Chavez <[email protected]>
Our load test is the worst possible case for this (and I would say: any bounded) cache: all JWTs are different so no caching but:
In other words - cache thrashing at its best :) What's more: in case of symmetric JWT keys I don't think cache lookup is faster than simply performing JWT verification. |
This is also important for the default of enabling the cache or not. Can we default to enabling the cache only for asymmetric keys? |
Hmm... Not a bad idea - sensible self-configuration is much better than forcing the user to make decisions. On the other hand we don't have any cache sizing self-tuning, so the user has to configure this anyway. |
@steve-chavez @wolfgangwalther Looks like combining the default cache size from #4084 (comment) with auto-configuration of the cache idea from #4084 (comment) allows us to have a pretty good user experience:
Implemented in c6e73b1 |
|
With the new default, we should change the JWT load test to use asymmetric keys - and then see how much perf we lose in that worst case. |
I thought about it but decided not to implement this and the reasoning is:
This is somewhat surprising - we do not remove from the cache explicitly right now, so it should be always full (hence occupying constant memory). Maybe what you see is GC effects? |
Cool. Fair enough.
Right. It looks like it's indeed GC as RSS and VSZ both decrease simultaneously as seen on: Screencast.from.05-28-2025.02.12.05.PM.webmI detected a problem with the previous JWT cache that this PR solves #4107. |
* Changed postgrest-loadtest -k jwt to generate 1000 JWT signed by RSA 4096 key. * Added parameter --jwtcache=off to postgrest-loadtest to turn off JWT caching
@wolfgangwalther @steve-chavez @taimoorzaeem I've changed load test to use 1000 JWTs signed by RSA4096 key. Also added additional parameter In case of asymmetric RSA, caching gives around 4x performance boost (all response times up to 95th percentile are 4x lower with caching turned on). |
@mkleczek Cool! Can you split those commits to another PR for easier review/merge? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The commit history in the PR currently represents which steps you took while you were working on it. Now, we need to get it into a reviewable and mergeable shape. That's because we can't squash merge, but also not merge it as-is for a clean history.
So, it would be great if you could rebase to create a nice history of self-contained commits. At least with the following:
- refactor for the split of auth module
- refactor in the Metrics module
- improving the jwt loadtest as Steve mentioned
- ... probably some more refactors that I just didn't spot, but which are independent of the main change ...
- and finally the main change to change the JWT cache implementation
We'll need to slice this nicely, otherwise there is no chance to review this properly. I still left a few nits while skimming the code.
@@ -97,7 +97,8 @@ data AppConfig = AppConfig | |||
, configJwtRoleClaimKey :: JSPath | |||
, configJwtSecret :: Maybe BS.ByteString | |||
, configJwtSecretIsBase64 :: Bool | |||
, configJwtCacheMaxLifetime :: Int | |||
-- , configJwtCacheMaxLifetime :: Int |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are some left-overs in this file (more below)
poolTimeouts <- register $ counter (Info "pgrst_db_pool_timeouts_total" "The total number of pool connection timeouts") | ||
poolAvailable <- register $ gauge (Info "pgrst_db_pool_available" "Available connections in the pool") | ||
poolWaiting <- register $ gauge (Info "pgrst_db_pool_waiting" "Requests waiting to acquire a pool connection") | ||
poolMaxSize <- register $ gauge (Info "pgrst_db_pool_max" "Max pool connections") | ||
schemaCacheLoads <- register $ vector "status" $ counter (Info "pgrst_schema_cache_loads_total" "The total number of times the schema cache was loaded") | ||
schemaCacheQueryTime <- register $ gauge (Info "pgrst_schema_cache_query_time_seconds" "The query time in seconds of the last schema cache load") | ||
setGauge poolMaxSize (fromIntegral configDbPoolSize) | ||
pure $ MetricsState poolTimeouts poolAvailable poolWaiting poolMaxSize schemaCacheLoads schemaCacheQueryTime | ||
metricState <- MetricsState <$> | ||
register (counter (Info "pgrst_db_pool_timeouts_total" "The total number of pool connection timeouts")) <*> | ||
register (gauge (Info "pgrst_db_pool_available" "Available connections in the pool")) <*> | ||
register (gauge (Info "pgrst_db_pool_waiting" "Requests waiting to acquire a pool connection")) <*> | ||
register (gauge (Info "pgrst_db_pool_max" "Max pool connections")) <*> | ||
register (vector "status" $ counter (Info "pgrst_schema_cache_loads_total" "The total number of times the schema cache was loaded")) <*> | ||
register (gauge (Info "pgrst_schema_cache_query_time_seconds" "The query time in seconds of the last schema cache load")) <*> | ||
register (counter (Info "pgrst_jwt_cache_requests_total" "The total number of JWT cache lookups")) <*> | ||
register (counter (Info "pgrst_jwt_cache_hits_total" "The total number of JWT cache hits")) <*> | ||
register (counter (Info "pgrst_jwt_cache_evictions_total" "The total number of JWT cache evictions")) | ||
setGauge (poolMaxSize metricState) (fromIntegral configDbPoolSize) | ||
pure metricState |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most of this change could be a separate refactor commit.
src/PostgREST/Auth/Jwt.hs
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file should also be created in a separate refactor commit ahead of all the other changes.
The problem with this is that currently in main we don't really have a proper interface for caching and everything is intermingled. I thought it does not really make too much sense to perform the refactoring while leaving current JWT cache implementation.
+1
+1
See above.
Thanks for this review. |
Co-authored-by: Wolfgang Walther <[email protected]>
Co-authored-by: Wolfgang Walther <[email protected]>
Draft version of JWT cache implementation based on https://cachemon.github.io/SIEVE-website/blog/2023/12/17/sieve-is-simpler-than-lru/ algorithm