Skip to content

Commit a0274d4

Browse files
committed
test: add loadtest for async purge of JWT cache
1 parent c732591 commit a0274d4

File tree

4 files changed

+129
-10
lines changed

4 files changed

+129
-10
lines changed

.github/workflows/test.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ jobs:
111111

112112

113113
loadtest:
114+
strategy:
115+
matrix:
116+
kind: ['mixed', 'jwt']
114117
name: Loadtest
115118
runs-on: ubuntu-24.04
116119
steps:
@@ -128,7 +131,7 @@ jobs:
128131
prefix: v
129132
- name: Run loadtest
130133
run: |
131-
postgrest-loadtest-against main ${{ steps.get-latest-tag.outputs.tag }}
134+
postgrest-loadtest-against -k ${{ matrix.kind }} main ${{ steps.get-latest-tag.outputs.tag }}
132135
postgrest-loadtest-report >> "$GITHUB_STEP_SUMMARY"
133136
134137
flake:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ coverage
2424
loadtest
2525
.history
2626
.docs-build
27+
gen_targets.http

nix/tools/generate_targets.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# generates a file to be used by the vegeta load testing tool
2+
import time
3+
import hmac
4+
import hashlib
5+
import base64
6+
import json
7+
import argparse
8+
import sys
9+
import random
10+
11+
SECRET = b"reallyreallyreallyreallyverysafe"
12+
URL = "http://postgrest"
13+
JWT_DURATION = 120
14+
TOTAL_TARGETS = 50000 # tuned by hand to reduce result variance
15+
16+
17+
def base64url_encode(data: bytes) -> str:
18+
"""URL-safe Base64 encode without padding."""
19+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
20+
21+
22+
def generate_jwt(exp_inc: int) -> str:
23+
"""Generate an HS256 JWT"""
24+
# Header & payload
25+
header = {"alg": "HS256", "typ": "JWT"}
26+
now = int(time.time())
27+
payload = {
28+
"sub": f"user_{random.getrandbits(32)}",
29+
"iat": now,
30+
"exp": now + exp_inc,
31+
"role": "postgrest_test_author",
32+
}
33+
34+
# Encode to JSON and then to Base64URL
35+
header_b = json.dumps(header, separators=(",", ":")).encode()
36+
payload_b = json.dumps(payload, separators=(",", ":")).encode()
37+
header_b64 = base64url_encode(header_b)
38+
payload_b64 = base64url_encode(payload_b)
39+
40+
# Sign (HMAC‑SHA256) the "<header>.<payload>" string
41+
signing_input = f"{header_b64}.{payload_b64}".encode()
42+
signature = hmac.new(SECRET, signing_input, hashlib.sha256).digest()
43+
signature_b64 = base64url_encode(signature)
44+
45+
return f"{header_b64}.{payload_b64}.{signature_b64}"
46+
47+
48+
# We want to ensure 401 Unauthorized responses don't happen during
49+
# JWT validation, this can happen when the jwt `exp` is too short.
50+
# At the same time, we want to ensure the `exp` is not too big,
51+
# so expires will occur and postgREST will have to clean cached expired JWTs.
52+
def estimate_adequate_jwt_exp_increase(iteration: int) -> int:
53+
# estimated time takes to build and run postgrest itself
54+
build_run_postgrest_time = 2
55+
# estimated time it takes to generate the targets file
56+
file_generation_time = TOTAL_TARGETS//(10**-5)
57+
# estimated exp time so some JWTs will expire
58+
dynamic_exp_inc = iteration//1000
59+
60+
return build_run_postgrest_time + file_generation_time + dynamic_exp_inc
61+
62+
63+
def main():
64+
parser = argparse.ArgumentParser(
65+
description="Generate Vegeta targets with unique JWTs"
66+
)
67+
parser.add_argument(
68+
"output",
69+
help="Path to write the generated targets file",
70+
)
71+
args = parser.parse_args()
72+
73+
lines = []
74+
start_time = time.time()
75+
76+
for i in range(TOTAL_TARGETS):
77+
token = generate_jwt(estimate_adequate_jwt_exp_increase(i))
78+
lines.append(f"OPTIONS {URL}/authors_only")
79+
lines.append(f"Authorization: Bearer {token}")
80+
lines.append("") # blank line to separate requests
81+
82+
try:
83+
with open(args.output, "w") as f:
84+
f.write("\n".join(lines))
85+
except IOError as e:
86+
print(f"Error writing to {args.output}: {e}", file=sys.stderr)
87+
sys.exit(1)
88+
89+
elapsed = time.time() - start_time
90+
print(f"Created {TOTAL_TARGETS} targets in {args.output} ({elapsed:.2f}s)")
91+
92+
93+
if __name__ == "__main__":
94+
main()

nix/tools/loadtest.nix

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ let
4141
args = [
4242
"ARG_OPTIONAL_SINGLE([output], [o], [Filename to dump json output to], [./loadtest/result.bin])"
4343
"ARG_OPTIONAL_SINGLE([testdir], [t], [Directory to load tests and fixtures from], [./test/load])"
44+
"ARG_OPTIONAL_SINGLE([kind], [k], [Kind of loadtest (mixed: repeat mixed requests, jwt: run once over many requests with unique jwts)], [mixed])"
45+
"ARG_TYPE_GROUP_SET([KIND], [KIND], [kind], [mixed,jwt])"
4446
"ARG_LEFTOVERS([additional vegeta arguments])"
4547
];
4648
workingDir = "/";
@@ -61,13 +63,30 @@ let
6163
mkdir -p "$(dirname "$_arg_output")"
6264
abs_output="$(realpath "$_arg_output")"
6365
64-
# shellcheck disable=SC2145
65-
${withTools.withPg} -f "$_arg_testdir"/fixtures.sql \
66-
${withTools.withSlowPg} \
67-
${withTools.withPgrst} \
68-
${withTools.withSlowPgrst} \
69-
sh -c "cd \"$_arg_testdir\" && ${runner} -targets targets.http -output \"$abs_output\" \"''${_arg_leftovers[@]}\""
70-
${vegeta}/bin/vegeta report -type=text "$_arg_output"
66+
case "$_arg_kind" in
67+
jwt)
68+
69+
${genTargets} "$_arg_testdir"/gen_targets.http
70+
71+
# shellcheck disable=SC2145
72+
${withTools.withPg} -f "$_arg_testdir"/fixtures.sql \
73+
${withTools.withPgrst} \
74+
sh -c "cd \"$_arg_testdir\" && ${runner} -lazy -targets gen_targets.http -output \"$abs_output\" \"''${_arg_leftovers[@]}\""
75+
${vegeta}/bin/vegeta report -type=text "$_arg_output"
76+
;;
77+
78+
*)
79+
80+
# shellcheck disable=SC2145
81+
${withTools.withPg} -f "$_arg_testdir"/fixtures.sql \
82+
${withTools.withSlowPg} \
83+
${withTools.withPgrst} \
84+
${withTools.withSlowPgrst} \
85+
sh -c "cd \"$_arg_testdir\" && ${runner} -targets targets.http -output \"$abs_output\" \"''${_arg_leftovers[@]}\""
86+
${vegeta}/bin/vegeta report -type=text "$_arg_output"
87+
;;
88+
89+
esac
7190
'';
7291

7392
loadtestAgainst =
@@ -85,6 +104,7 @@ let
85104
'';
86105
args = [
87106
"ARG_POSITIONAL_INF([target], [Commit-ish reference to compare with], 1)"
107+
"ARG_OPTIONAL_SINGLE([kind], [k], [Kind of loadtest], [mixed])"
88108
];
89109
positionalCompletion =
90110
''
@@ -108,7 +128,7 @@ let
108128
# Save the results in the current working tree, too,
109129
# otherwise they'd be lost in the temporary working tree
110130
# created by withTools.withGit.
111-
${withTools.withGit} "$tgt" ${loadtest} --output "$PWD/loadtest/$tgt.bin" --testdir "$PWD/test/load"
131+
${withTools.withGit} "$tgt" ${loadtest} -k "$_arg_kind" --output "$PWD/loadtest/$tgt.bin" --testdir "$PWD/test/load"
112132
113133
cat << EOF
114134
@@ -124,7 +144,7 @@ let
124144
125145
EOF
126146
127-
${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load"
147+
${loadtest} -k "$_arg_kind" --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load"
128148
129149
cat << EOF
130150
@@ -180,6 +200,7 @@ let
180200
| ${toMarkdown}
181201
'';
182202

203+
genTargets = writers.writePython3 "postgrest-gen-loadtest-targets" { } (builtins.readFile ./generate_targets.py);
183204
in
184205
buildToolbox {
185206
name = "postgrest-loadtest";

0 commit comments

Comments
 (0)