Skip to content

Commit 3b7bdce

Browse files
authored
Merge pull request #540 from MDA2AV/test/crud
New test - CRUD
2 parents ed934fa + a89c92a commit 3b7bdce

42 files changed

Lines changed: 630 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ Notable changes to test profiles, scoring, and validation.
44

55
## 2026-04-16
66

7+
### CRUD — realistic REST API benchmark (H/1.1 Isolated)
8+
9+
New `crud` profile that benchmarks a realistic REST API with four operations: paginated list, cached single-item read, create, and update.
10+
11+
**Workload mix:** 40% paginated list queries (two SQL queries each: data + count), 30% single-item reads (in-process cached with 1s TTL), 15% creates (INSERT with ON CONFLICT upsert), 15% updates (UPDATE + cache invalidation). Uses gcannon's `{RAND:min:max}` and `{SEQ:start}` placeholders for realistic per-request ID distribution — GET reads randomize across 50K items, POST creates use auto-incrementing IDs starting at 100K, PUT updates randomize across the existing 50K range.
12+
13+
**Cache-aside pattern:** Single-item reads use `IMemoryCache` (or equivalent) with 1s absolute expiration. First request returns `X-Cache: MISS`, subsequent requests within the TTL return `X-Cache: HIT`. PUT invalidates the cache entry, so the next read is a fresh DB query.
14+
15+
**Connections:** 512, 4,096. **CPU:** 64 threads (cores 0-31, 64-95). **Duration:** 10s per run, best of 3.
16+
17+
**Validation:** 7 checks — list pagination (count, total, page, rating structure), single-item read, cache-aside MISS→HIT sequence, 404 for missing items, POST 201 Created, read-back of created item, PUT with cache invalidation verification.
18+
719
### Production Stack — JWT auth, HybridCache, 10K-item CRUD
820

921
Major rework of the `production-stack` profile. The test now models a realistic CRUD API with stateless JWT authentication, a three-tier cache hierarchy under real pressure, and concurrent read+write load.

data/pgdb-seed.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100016,6 +100016,10 @@ COPY items (id, name, category, price, quantity, active, tags, rating_score, rat
100016100016

100017100017
-- Verify: SELECT COUNT(*) FROM items; -- should return 100000
100018100018

100019+
-- Sequence for CRUD test POST inserts. Starts above the seeded range
100020+
-- so explicit-ID inserts don't collide with seeded rows.
100021+
CREATE SEQUENCE IF NOT EXISTS items_id_seq START 100001;
100022+
100019100023
-- ── production-stack users table ────────────────────────────────────────────
100020100024
-- Used by the /api/me cache-aside path. Four seed rows matching the four
100021100025
-- sessions pre-seeded in data/redis-seed.txt (bench-session-001..004 map

docker/gcannon.Dockerfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
FROM ubuntu:24.04 AS build
22
ARG GCANNON_REF=main
3+
# CACHE_BUST is set to the current timestamp by the benchmark script so
4+
# docker always pulls the latest gcannon source instead of reusing a
5+
# stale git-clone layer. Without this, edits pushed to the gcannon repo
6+
# are invisible until the user manually runs --no-cache.
7+
ARG CACHE_BUST=0
38
RUN apt-get update && apt-get install -y --no-install-recommends \
49
gcc make git ca-certificates && rm -rf /var/lib/apt/lists/*
510
WORKDIR /deps
611
RUN git clone --branch liburing-2.9 --depth 1 https://github.com/axboe/liburing.git && \
712
cd liburing && ./configure --prefix=/usr && make -j"$(nproc)" -C src && make install -C src
813
WORKDIR /build
9-
RUN git clone https://github.com/MDA2AV/gcannon . && \
14+
RUN echo "cache_bust=$CACHE_BUST" && \
15+
git clone https://github.com/MDA2AV/gcannon . && \
1016
git checkout "$GCANNON_REF" && \
1117
make clean && make -j"$(nproc)"
1218

frameworks/aspnet-minimal/Handlers.cs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Buffers;
33
using System.Text.Json.Serialization;
44
using Microsoft.AspNetCore.Http.HttpResults;
5+
using Microsoft.Extensions.Caching.Memory;
56

67

78
[JsonSerializable(typeof(ResponseDto<ProcessedItem>))]
@@ -120,4 +121,161 @@ public static async Task<Results<JsonHttpResult<ResponseDto<DbResponseItemDto>>,
120121

121122
return TypedResults.Json(new ResponseDto<DbResponseItemDto>(items, items.Count), AppJsonContext.Default.ResponseDtoDbResponseItemDto);
122123
}
123-
}
124+
125+
// ── CRUD handlers ──────────────────────────────────────────────────
126+
//
127+
// Realistic REST API with paginated list, cached single-item read,
128+
// create, and update. In-process IMemoryCache with 1s TTL on single-
129+
// item reads, invalidated on PUT. List queries always hit Postgres
130+
// (two queries: data + count).
131+
132+
private static readonly MemoryCacheEntryOptions _crudCacheOpts =
133+
new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) };
134+
135+
private static readonly JsonSerializerOptions _crudJsonOpts =
136+
new(JsonSerializerDefaults.Web);
137+
138+
// GET /crud/items?category=X&page=N&limit=M — paginated list (always DB, never cached)
139+
public static async Task<IResult> CrudList(HttpRequest req)
140+
{
141+
if (AppData.PgDataSource is null)
142+
return TypedResults.Problem("DB not available");
143+
144+
var query = req.Query;
145+
var category = query["category"].ToString();
146+
if (string.IsNullOrEmpty(category)) category = "electronics";
147+
int.TryParse(query["page"], out var page);
148+
if (page < 1) page = 1;
149+
int.TryParse(query["limit"], out var limit);
150+
if (limit < 1 || limit > 50) limit = 10;
151+
var offset = (page - 1) * limit;
152+
153+
// Query 1: data
154+
await using var cmd = AppData.PgDataSource.CreateCommand(
155+
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " +
156+
"FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3");
157+
cmd.Parameters.AddWithValue(category);
158+
cmd.Parameters.AddWithValue(limit);
159+
cmd.Parameters.AddWithValue(offset);
160+
161+
await using var reader = await cmd.ExecuteReaderAsync();
162+
var items = new List<object>();
163+
while (await reader.ReadAsync())
164+
{
165+
items.Add(new
166+
{
167+
id = reader.GetInt32(0),
168+
name = reader.GetString(1),
169+
category = reader.GetString(2),
170+
price = reader.GetInt32(3),
171+
quantity = reader.GetInt32(4),
172+
active = reader.GetBoolean(5),
173+
tags = JsonSerializer.Deserialize<List<string>>(reader.GetString(6), AppJsonContext.Default.ListString)!,
174+
rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) }
175+
});
176+
}
177+
await reader.CloseAsync();
178+
179+
// Query 2: total count
180+
await using var countCmd = AppData.PgDataSource.CreateCommand(
181+
"SELECT COUNT(*) FROM items WHERE category = $1");
182+
countCmd.Parameters.AddWithValue(category);
183+
var total = (long)(await countCmd.ExecuteScalarAsync())!;
184+
185+
return TypedResults.Ok(new { items, total, page, limit });
186+
}
187+
188+
// GET /crud/items/{id} — single item, cached with 1s TTL
189+
public static async Task<IResult> CrudRead(int id, IMemoryCache cache, HttpContext ctx)
190+
{
191+
if (AppData.PgDataSource is null)
192+
return TypedResults.Problem("DB not available");
193+
194+
var cacheKey = $"crud:{id}";
195+
if (cache.TryGetValue(cacheKey, out object? cached))
196+
{
197+
ctx.Response.Headers["X-Cache"] = "HIT";
198+
return TypedResults.Ok(cached);
199+
}
200+
201+
await using var cmd = AppData.PgDataSource.CreateCommand(
202+
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " +
203+
"FROM items WHERE id = $1 LIMIT 1");
204+
cmd.Parameters.AddWithValue(id);
205+
206+
await using var reader = await cmd.ExecuteReaderAsync();
207+
if (!await reader.ReadAsync())
208+
return TypedResults.NotFound();
209+
210+
var item = new
211+
{
212+
id = reader.GetInt32(0),
213+
name = reader.GetString(1),
214+
category = reader.GetString(2),
215+
price = reader.GetInt32(3),
216+
quantity = reader.GetInt32(4),
217+
active = reader.GetBoolean(5),
218+
tags = JsonSerializer.Deserialize<List<string>>(reader.GetString(6), AppJsonContext.Default.ListString)!,
219+
rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) }
220+
};
221+
222+
cache.Set(cacheKey, item, _crudCacheOpts);
223+
ctx.Response.Headers["X-Cache"] = "MISS";
224+
return TypedResults.Ok(item);
225+
}
226+
227+
// POST /crud/items — create item, return 201
228+
public static async Task<IResult> CrudCreate(HttpRequest req)
229+
{
230+
if (AppData.PgDataSource is null)
231+
return TypedResults.Problem("DB not available");
232+
233+
using var sr = new StreamReader(req.Body);
234+
var body = await sr.ReadToEndAsync();
235+
var input = JsonSerializer.Deserialize<CrudItemInput>(body, _crudJsonOpts);
236+
if (input is null)
237+
return TypedResults.BadRequest();
238+
239+
await using var cmd = AppData.PgDataSource.CreateCommand(
240+
"INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) " +
241+
"VALUES ($1, $2, $3, $4, $5, true, '[\"bench\"]', 0, 0) " +
242+
"ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 " +
243+
"RETURNING id");
244+
cmd.Parameters.AddWithValue(input.Id);
245+
cmd.Parameters.AddWithValue(input.Name ?? "New Product");
246+
cmd.Parameters.AddWithValue(input.Category ?? "test");
247+
cmd.Parameters.AddWithValue(input.Price);
248+
cmd.Parameters.AddWithValue(input.Quantity);
249+
250+
var newId = (int)(await cmd.ExecuteScalarAsync())!;
251+
return TypedResults.Created($"/crud/items/{newId}", new { id = newId, name = input.Name, category = input.Category, price = input.Price, quantity = input.Quantity });
252+
}
253+
254+
// PUT /crud/items/{id} — update item, invalidate cache
255+
public static async Task<IResult> CrudUpdate(int id, HttpRequest req, IMemoryCache cache)
256+
{
257+
if (AppData.PgDataSource is null)
258+
return TypedResults.Problem("DB not available");
259+
260+
using var sr = new StreamReader(req.Body);
261+
var body = await sr.ReadToEndAsync();
262+
var input = JsonSerializer.Deserialize<CrudItemInput>(body, _crudJsonOpts);
263+
if (input is null)
264+
return TypedResults.BadRequest();
265+
266+
await using var cmd = AppData.PgDataSource.CreateCommand(
267+
"UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4");
268+
cmd.Parameters.AddWithValue(input.Name ?? "Updated");
269+
cmd.Parameters.AddWithValue(input.Price);
270+
cmd.Parameters.AddWithValue(input.Quantity);
271+
cmd.Parameters.AddWithValue(id);
272+
273+
var affected = await cmd.ExecuteNonQueryAsync();
274+
if (affected == 0) return TypedResults.NotFound();
275+
276+
cache.Remove($"crud:{id}");
277+
return TypedResults.Ok(new { id, name = input.Name, price = input.Price, quantity = input.Quantity });
278+
}
279+
}
280+
281+
record CrudItemInput(int Id, string? Name, string? Category, int Price, int Quantity);

frameworks/aspnet-minimal/Program.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
using Microsoft.AspNetCore.Server.Kestrel.Core;
44
using Microsoft.AspNetCore.StaticFiles;
5+
using Microsoft.Extensions.Caching.Memory;
56

67
var builder = WebApplication.CreateBuilder(args);
78
builder.Logging.ClearProviders();
9+
builder.Services.AddMemoryCache();
810

911
var certPath = Environment.GetEnvironmentVariable("TLS_CERT") ?? "/certs/server.crt";
1012
var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key";
@@ -64,6 +66,15 @@
6466
app.MapGet("/json/{count}", Handlers.Json);
6567
app.MapGet("/async-db", Handlers.AsyncDatabase);
6668

69+
// ── CRUD endpoints ─────────────────────────────────────────────────────────
70+
// Realistic REST API: paginated list, cached single-item read, create, update.
71+
// In-process IMemoryCache with 1s TTL on single-item reads, invalidated on PUT.
72+
73+
app.MapGet("/crud/items", Handlers.CrudList);
74+
app.MapGet("/crud/items/{id:int}", Handlers.CrudRead);
75+
app.MapPost("/crud/items", Handlers.CrudCreate);
76+
app.MapPut("/crud/items/{id:int}", Handlers.CrudUpdate);
77+
6778
app.MapStaticAssets();
6879

6980
app.Run();

frameworks/aspnet-minimal/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Minimal ASP.NET Core HTTP server using .NET 10 with Kestrel and minimal API rout
2020
| `/json/{count}` | GET | Returns `count` items from the preloaded dataset; honors `Accept-Encoding: gzip/br/deflate` for the `json-comp` profile |
2121
| `/async-db` | GET | Postgres range query: `SELECT ... WHERE price BETWEEN $min AND $max LIMIT $limit` |
2222
| `/upload` | POST | Streams the request body and returns the byte count |
23+
| `/crud/items` | GET | Paginated list by category with two queries (data + count) |
24+
| `/crud/items/{id}` | GET | Single item read with `IMemoryCache` (1s TTL), returns `X-Cache: HIT/MISS` |
25+
| `/crud/items` | POST | Create item via INSERT with ON CONFLICT upsert, returns 201 |
26+
| `/crud/items/{id}` | PUT | Update item and invalidate cache entry |
2327
| `/static/*` | GET | Serves files from `/data/static` via `MapStaticAssets` with precomputed ETags + compression |
2428

2529
## Notes

frameworks/aspnet-minimal/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"api-16",
1919
"static",
2020
"async-db",
21+
"crud",
2122
"baseline-h2",
2223
"static-h2",
2324
"baseline-h3",

requests/crud-create-1.raw

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
POST /crud/items HTTP/1.1
2+
Host: localhost:8080
3+
Content-Type: application/json
4+
Content-Length: 78
5+
6+
{"id":{SEQ:100001},"name":"New Product","category":"test","price":150,"quantity":30}

requests/crud-create-2.raw

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
POST /crud/items HTTP/1.1
2+
Host: localhost:8080
3+
Content-Type: application/json
4+
Content-Length: 78
5+
6+
{"id":{SEQ:100001},"name":"New Product","category":"test","price":150,"quantity":30}

requests/crud-create-3.raw

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
POST /crud/items HTTP/1.1
2+
Host: localhost:8080
3+
Content-Type: application/json
4+
Content-Length: 78
5+
6+
{"id":{SEQ:100001},"name":"New Product","category":"test","price":150,"quantity":30}

0 commit comments

Comments
 (0)