test(loadtest): 100-guest / 1000-image stress harness
HTTP-level load driver simulating ~100 guests uploading ~1000 images in bursts over a window, plus SSE viewers and one real browser on /diashow. Correlates upload→upload-processed (pipeline latency), waits for the compression backlog to drain against DB ground truth, and emits per-status/latency metrics with pass/fail flags. Includes a realistic-JPEG generator and a diashow-SSE regression check (confirm-diashow-fix.mjs). Run artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
146
e2e/loadtest/gen-images.py
Normal file
146
e2e/loadtest/gen-images.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a pool of realistic phone-sized JPEGs for the EventSnap load test.
|
||||
|
||||
The stress test uploads ~1000 images, but they don't need to be 1000 unique
|
||||
files — real load comes from realistic *byte size* and *decode cost*, which
|
||||
drive bandwidth, the compression/preview pipeline (decode + 800x800 resize),
|
||||
disk usage and the dynamic storage quota. So we generate a modest POOL of
|
||||
distinct, high-entropy images (~2-4 MB, 12 MP, like a phone camera) and the
|
||||
driver reuses them at random across the 1000 uploads.
|
||||
|
||||
High entropy matters: a flat gradient compresses to almost nothing and would
|
||||
under-stress both the network and the JPEG decoder. We blend random noise over
|
||||
a colorful gradient + big shapes so the files land in a realistic size band,
|
||||
then tune JPEG quality per-image to hit the target size.
|
||||
|
||||
Output: $LT_PHOTOS_DIR (default /tmp/eventsnap-loadtest/photos)
|
||||
Usage: python3 gen-images.py [COUNT] (default 40)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import random
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
OUT_DIR = os.environ.get("LT_PHOTOS_DIR", "/tmp/eventsnap-loadtest/photos")
|
||||
COUNT = int(sys.argv[1]) if len(sys.argv) > 1 else 40
|
||||
|
||||
# Target JPEG size band (bytes). Typical modern phone photo.
|
||||
TARGET_MIN = 2_000_000
|
||||
TARGET_MAX = 4_500_000
|
||||
|
||||
# 12 MP-ish, both orientations (phones shoot portrait and landscape).
|
||||
SIZES = [(4032, 3024), (3024, 4032)]
|
||||
|
||||
PALETTES = [
|
||||
[(250, 245, 235), (212, 175, 55), (120, 90, 30)], # champagne / gold
|
||||
[(245, 244, 242), (190, 190, 198), (90, 92, 100)], # silver / pearl
|
||||
[(255, 250, 240), (240, 200, 160), (170, 110, 70)], # warm sunset
|
||||
[(235, 240, 248), (140, 170, 210), (40, 70, 120)], # cool blue hour
|
||||
[(248, 240, 245), (210, 150, 180), (110, 50, 90)], # rose dusk
|
||||
[(240, 248, 242), (150, 200, 170), (40, 110, 80)], # garden green
|
||||
]
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
||||
|
||||
|
||||
def gradient(w, h, palette, rng):
|
||||
"""Diagonal 3-stop gradient base."""
|
||||
base = Image.new("RGB", (w, h))
|
||||
px = base.load()
|
||||
ang = rng.uniform(0, math.pi)
|
||||
dx, dy = math.cos(ang), math.sin(ang)
|
||||
# precompute per-column/row projection for speed
|
||||
maxproj = abs(dx) * w + abs(dy) * h
|
||||
for y in range(h):
|
||||
for x in range(0, w, 4): # step 4 then fill — good enough, much faster
|
||||
t = (dx * x + dy * y) / maxproj
|
||||
t = min(1.0, max(0.0, t + rng.uniform(-0.02, 0.02)))
|
||||
if t < 0.5:
|
||||
c = lerp(palette[0], palette[1], t * 2)
|
||||
else:
|
||||
c = lerp(palette[1], palette[2], (t - 0.5) * 2)
|
||||
for k in range(4):
|
||||
if x + k < w:
|
||||
px[x + k, y] = c
|
||||
return base
|
||||
|
||||
|
||||
def add_shapes(img, rng):
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
w, h = img.size
|
||||
for _ in range(rng.randint(6, 14)):
|
||||
x0 = rng.randint(-w // 5, w)
|
||||
y0 = rng.randint(-h // 5, h)
|
||||
r = rng.randint(w // 12, w // 3)
|
||||
col = (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255), rng.randint(20, 90))
|
||||
if rng.random() < 0.5:
|
||||
d.ellipse([x0, y0, x0 + r, y0 + r], fill=col)
|
||||
else:
|
||||
d.rectangle([x0, y0, x0 + r, y0 + int(r * rng.uniform(0.4, 1.6))], fill=col)
|
||||
return img
|
||||
|
||||
|
||||
def noisy(w, h, rng):
|
||||
"""Full-resolution RGB noise from urandom — maximum entropy."""
|
||||
return Image.frombytes("RGB", (w, h), os.urandom(w * h * 3))
|
||||
|
||||
|
||||
def label(img, idx, rng):
|
||||
d = ImageDraw.Draw(img)
|
||||
txt = f"EventSnap load #{idx:03d}"
|
||||
try:
|
||||
font = ImageFont.load_default(size=64)
|
||||
except TypeError:
|
||||
font = ImageFont.load_default()
|
||||
d.text((60, 60), txt, fill=(255, 255, 255), font=font)
|
||||
d.text((62, 62), txt, fill=(0, 0, 0), font=font) # cheap shadow offset
|
||||
|
||||
|
||||
def encode_to_band(img, path, rng):
|
||||
"""Try qualities high→low until the file lands under TARGET_MAX; keep the
|
||||
first that also clears TARGET_MIN if possible."""
|
||||
best = None
|
||||
for q in (92, 88, 84, 80, 76, 72):
|
||||
img.save(path, "JPEG", quality=q, optimize=False)
|
||||
size = os.path.getsize(path)
|
||||
best = (q, size)
|
||||
if size <= TARGET_MAX:
|
||||
if size >= TARGET_MIN:
|
||||
return q, size
|
||||
# under the band — noise alpha likely too low; accept anyway at high q
|
||||
return q, size
|
||||
return best # even q72 too big; accept the smallest we made
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
rng = random.Random(20260718) # deterministic pool
|
||||
total_bytes = 0
|
||||
print(f"[gen] writing {COUNT} images to {OUT_DIR}")
|
||||
for i in range(COUNT):
|
||||
w, h = rng.choice(SIZES)
|
||||
palette = rng.choice(PALETTES)
|
||||
base = gradient(w, h, palette, rng)
|
||||
base = add_shapes(base, rng)
|
||||
# blend noise to inject entropy -> realistic JPEG size
|
||||
alpha = rng.uniform(0.28, 0.42)
|
||||
base = Image.blend(base, noisy(w, h, rng), alpha)
|
||||
label(base, i, rng)
|
||||
path = os.path.join(OUT_DIR, f"photo_{i:03d}.jpg")
|
||||
q, size = encode_to_band(base, path, rng)
|
||||
total_bytes += size
|
||||
print(f" photo_{i:03d}.jpg {w}x{h} q{q} {size/1_000_000:.2f} MB")
|
||||
avg = total_bytes / COUNT
|
||||
print(f"[gen] done. {COUNT} images, avg {avg/1_000_000:.2f} MB, "
|
||||
f"pool total {total_bytes/1_000_000:.1f} MB")
|
||||
print(f"[gen] projected for 1000 uploads (originals only): "
|
||||
f"~{avg*1000/1_000_000_000:.1f} GB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user