Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce6dbdab56 | |||
| 7cd7654f99 | |||
| 415497ad06 | |||
| 98d25601b4 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.131.5-beta.0"
|
||||
".": "3.131.7-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.131.7-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.6-beta.0...v3.131.7-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** keep header-style tiles from overflowing their cards ([#1422](https://github.com/PicPeak/picpeak/issues/1422)) ([7cd7654](https://github.com/PicPeak/picpeak/commit/7cd7654f99967080724d7498b72bea4986ae825d))
|
||||
|
||||
## [3.131.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.5-beta.0...v3.131.6-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** cap how many cached zips rebuild at once in the background ([#1418](https://github.com/PicPeak/picpeak/issues/1418)) ([98d2560](https://github.com/PicPeak/picpeak/commit/98d25601b465ecc44d0875b52891073720fabbbc))
|
||||
|
||||
## [3.131.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.4-beta.0...v3.131.5-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Background zip rebuilds are capped (#1399).
|
||||
*
|
||||
* invalidateAll() invalidates every event holding a cached zip, and each
|
||||
* invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
* together. Every build opens its own storage reads, so a settings change
|
||||
* across 25 events was enough to exhaust the S3 agent pool and stall uploads,
|
||||
* thumbnails and gallery reads until the burst drained.
|
||||
*
|
||||
* The cap is on the BACKGROUND path only: a guest waiting on a download must
|
||||
* not be queued behind a settings-change burst.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../../src/database/db');
|
||||
const service = require('../../src/services/downloadZipService');
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe('downloadZipService background regen concurrency (#1399)', () => {
|
||||
let peak;
|
||||
let inFlight;
|
||||
let release;
|
||||
|
||||
beforeEach(() => {
|
||||
// setImmediate must stay real: the flush() helper below rides on it, and
|
||||
// jest's modern fake timers mock it too.
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
peak = 0;
|
||||
inFlight = 0;
|
||||
release = [];
|
||||
service.stopped = false;
|
||||
service.regenActive = 0;
|
||||
service.regenWaiters = [];
|
||||
service.debounceTimers.clear();
|
||||
service.activeBuilds.clear();
|
||||
|
||||
jest.spyOn(service, 'generateZip').mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => { inFlight -= 1; resolve(); });
|
||||
});
|
||||
});
|
||||
jest.spyOn(service, '_cleanup').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('never runs more than two rebuilds at once, however many fire together', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
// Every debounce timer was armed in the same tick — fire them all.
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
|
||||
expect(peak).toBe(2);
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts the next rebuild as each one finishes', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
release.shift()();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(3);
|
||||
expect(peak).toBe(2);
|
||||
|
||||
while (release.length) { release.shift()(); await flush(); }
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(5);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('does not queue a foreground download behind the burst', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A guest asking for a zip right now calls generateZip directly. It must
|
||||
// not park behind the two rebuilds already holding the slots.
|
||||
service.generateZip(999);
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledWith(999);
|
||||
expect(inFlight).toBe(3);
|
||||
});
|
||||
|
||||
it('releases anything parked for a slot on shutdown', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.regenWaiters.length).toBeGreaterThan(0);
|
||||
|
||||
// stop() must not hang on a queue that will never drain.
|
||||
const stopping = service.stop();
|
||||
release.forEach((fn) => fn());
|
||||
await expect(stopping).resolves.toBeUndefined();
|
||||
expect(service.regenWaiters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.131.5-beta.0",
|
||||
"version": "3.131.7-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -38,6 +38,18 @@ const DEBOUNCE_MS = 5000;
|
||||
// Two keeps the next photo's round trip overlapped with the current write
|
||||
// without ever leaving more than one socket idle.
|
||||
const MAX_INFLIGHT_READS = 2;
|
||||
// How many cached zips may be REBUILT at once in the background (#1399).
|
||||
//
|
||||
// invalidateAll() invalidates every event that has a cached zip, and each
|
||||
// invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
// together and, before this, every one of them started building at once. Each
|
||||
// build opens its own storage reads, so 25 events was enough to exhaust the S3
|
||||
// agent pool and stall uploads, thumbnails and gallery reads until the burst
|
||||
// finished.
|
||||
//
|
||||
// This caps the BACKGROUND path only. A foreground generateZip() — a guest
|
||||
// actually waiting for a download — is never queued behind a rebuild.
|
||||
const MAX_CONCURRENT_REGENS = 2;
|
||||
|
||||
class DownloadZipService {
|
||||
constructor() {
|
||||
@@ -45,11 +57,42 @@ class DownloadZipService {
|
||||
this.debounceTimers = new Map(); // eventId -> setTimeout handle
|
||||
this.versions = new Map(); // eventId -> generation counter
|
||||
this.buildCancellers = new Map(); // eventId -> abort the in-flight build
|
||||
this.regenActive = 0; // background rebuilds running right now
|
||||
this.regenWaiters = []; // resolvers parked waiting for a slot
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a BACKGROUND rebuild under the concurrency cap (#1399). Foreground
|
||||
* callers deliberately do not go through here: someone is waiting on that
|
||||
* response, and making them queue behind a settings-change burst would trade
|
||||
* one stall for another.
|
||||
*/
|
||||
async _withRegenSlot(fn) {
|
||||
if (this.stopped) return undefined;
|
||||
if (this.regenActive >= MAX_CONCURRENT_REGENS) {
|
||||
await new Promise((resolve) => this.regenWaiters.push(resolve));
|
||||
// Shutdown can drain the queue while we were parked.
|
||||
if (this.stopped) return undefined;
|
||||
}
|
||||
this.regenActive += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.regenActive -= 1;
|
||||
const next = this.regenWaiters.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.stopped = true;
|
||||
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
|
||||
this.debounceTimers.clear();
|
||||
// Release anything parked for a slot so shutdown can't hang on a queue
|
||||
// that will never drain — they check `stopped` and return without building.
|
||||
const waiters = this.regenWaiters.splice(0);
|
||||
for (const resume of waiters) resume();
|
||||
await Promise.allSettled([...this.activeBuilds.values()].map(build => build.promise));
|
||||
this.versions.clear();
|
||||
this.buildCancellers.clear();
|
||||
@@ -364,7 +407,9 @@ class DownloadZipService {
|
||||
// Debounce regeneration
|
||||
const newTimer = setTimeout(() => {
|
||||
this.debounceTimers.delete(eventId);
|
||||
this.generateZip(eventId).catch(err =>
|
||||
// Through the cap (#1399): invalidateAll arms every one of these in the
|
||||
// same tick, so without it they all start building together.
|
||||
this._withRegenSlot(() => this.generateZip(eventId)).catch(err =>
|
||||
logger.warn('downloadZipService debounced regen error', { eventId, error: err.message })
|
||||
);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.131.5-beta.0",
|
||||
"version": "3.131.7-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -22,7 +22,13 @@ export const HeaderStyleCard: React.FC<HeaderStyleCardProps> = ({ localTheme, ha
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* auto-fit/minmax rather than viewport breakpoints (#1412): the
|
||||
breakpoints size the columns off the WINDOW, but this card sits in a
|
||||
settings panel that is far narrower, so `lg:grid-cols-3` produced
|
||||
three ~85px columns no German string could fit in. A minimum track
|
||||
width lets the column count follow the container instead, and drops
|
||||
to fewer columns when there is no room. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-4">
|
||||
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -34,14 +40,21 @@ export const HeaderStyleCard: React.FC<HeaderStyleCardProps> = ({ localTheme, ha
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* min-w-0 + break-words: a grid item will not shrink below its
|
||||
min-content width, and German compounds here are long enough to
|
||||
exceed a narrow column — "Veranstaltungsinfo-Overlay" and
|
||||
"Veranstaltungsdetails" spilled out of the card and over the
|
||||
neighbouring one at three columns in a narrow panel (#1412).
|
||||
Any translation can do this, so the constraint belongs on the
|
||||
element rather than on the strings. */}
|
||||
<div className="flex flex-col items-center text-center w-full min-w-0">
|
||||
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
|
||||
{headerStyleIcons[style]}
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
|
||||
<span className="w-full break-words font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
|
||||
{t(`branding.headerStyleOptions.${style}`, style)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
<span className="w-full break-words text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t(`branding.headerStyleDescriptions.${style}`, '')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -61,7 +74,7 @@ export const HeaderStyleCard: React.FC<HeaderStyleCardProps> = ({ localTheme, ha
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(6rem,1fr))] gap-3">
|
||||
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -73,12 +86,12 @@ export const HeaderStyleCard: React.FC<HeaderStyleCardProps> = ({ localTheme, ha
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex flex-col items-center w-full min-w-0">
|
||||
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
|
||||
<div className="h-8"></div>
|
||||
{dividerStylePreviews[divider]}
|
||||
</div>
|
||||
<span className="text-xs font-medium capitalize text-neutral-900 dark:text-neutral-100">
|
||||
<span className="w-full break-words text-xs font-medium capitalize text-neutral-900 dark:text-neutral-100">
|
||||
{t(`branding.dividerOptions.${divider}`, divider)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user