perf/test: Build-Coalescing teilen, syncLibrary drosseln, API-Tests
- coalesce.js: generisches Serialisieren+Koaleszieren je Key; buildSite() in hugo.js nutzt es → Publish/Preview/Profil starten nie überlappende Hugo- Prozesse, schnelle Folge-Aufrufe lösen nur einen Trailing-Build aus - dialog-store: syncLibrary() gedrosselt (60s-TTL) statt bei jedem Forum-Read Filesystem-Walk + Upsert; Publish forciert Sync (force:true) - test/: node:test-Suite (19 Tests) für safeRel/normAuthors/urlFor/hasAccess, roleOf + lokale JWT-Verifikation, Rate-Limiter, Coalescing; npm test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende:
|
||||
// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig.
|
||||
// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer
|
||||
// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen
|
||||
// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm.
|
||||
//
|
||||
// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js).
|
||||
const state = new Map();
|
||||
|
||||
export function coalesce(key, fn) {
|
||||
let s = state.get(key);
|
||||
if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); }
|
||||
s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf
|
||||
return new Promise((resolve, reject) => {
|
||||
s.waiters.push({ resolve, reject });
|
||||
if (!s.running) drain(key);
|
||||
else s.rerun = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function drain(key) {
|
||||
const s = state.get(key);
|
||||
s.running = true;
|
||||
try {
|
||||
do {
|
||||
s.rerun = false;
|
||||
const waiters = s.waiters;
|
||||
s.waiters = [];
|
||||
try {
|
||||
const r = await s.fn();
|
||||
waiters.forEach((w) => w.resolve(r));
|
||||
} catch (e) {
|
||||
waiters.forEach((w) => w.reject(e));
|
||||
}
|
||||
} while (s.rerun);
|
||||
} finally {
|
||||
s.running = false;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,15 @@ export async function profileFor(email) {
|
||||
|
||||
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||
export async function syncLibrary() {
|
||||
//
|
||||
// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche
|
||||
// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms.
|
||||
// `force: true` (z.B. nach Publish) überspringt die Drosselung.
|
||||
const SYNC_TTL = 60_000;
|
||||
let lastSync = 0;
|
||||
export async function syncLibrary({ force = false } = {}) {
|
||||
if (!force && Date.now() - lastSync < SYNC_TTL) return;
|
||||
lastSync = Date.now();
|
||||
const { data: forum } = await supabase
|
||||
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||
if (!forum) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { coalesce } from './coalesce.js';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
@@ -16,6 +17,13 @@ export async function hugoBuild({ dest, drafts = false } = {}) {
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
// Koaleszierter Build je Ziel: nie zwei `hugo`-Prozesse für dasselbe dest
|
||||
// parallel; schnelle Folge-Aufrufe lösen nur einen nachgelagerten Build aus.
|
||||
// Publish (public), Preview (preview) und Profil teilen sich diesen Weg.
|
||||
export function buildSite({ dest, drafts = false } = {}) {
|
||||
return coalesce(`build:${dest}:${drafts ? 'd' : 'p'}`, () => hugoBuild({ dest, drafts }));
|
||||
}
|
||||
|
||||
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
||||
// das Publish soll an einem Git-Problem nicht scheitern.
|
||||
export async function gitCommit(message) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { hugoBuild } from '../hugo.js';
|
||||
import { buildSite } from '../hugo.js';
|
||||
|
||||
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||
@@ -10,7 +10,7 @@ preview.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
||||
const build = await buildSite({ dest: 'preview', drafts: true });
|
||||
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { hugoBuild } from '../hugo.js';
|
||||
import { buildSite } from '../hugo.js';
|
||||
|
||||
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
||||
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
||||
@@ -19,20 +19,6 @@ function slugify(s) {
|
||||
}
|
||||
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
|
||||
|
||||
// Hugo-Build koaleszieren: nie zwei parallel, und schnelle Folge-Speicherungen
|
||||
// lösen nur EINEN nachgelagerten Build aus (verhindert Build-Sturm/DoS).
|
||||
let building = false, rerun = false;
|
||||
async function rebuildSite() {
|
||||
if (building) { rerun = true; return; }
|
||||
building = true;
|
||||
try {
|
||||
do {
|
||||
rerun = false;
|
||||
await hugoBuild({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
||||
} while (rerun);
|
||||
} finally { building = false; }
|
||||
}
|
||||
|
||||
const profile = new Hono();
|
||||
|
||||
profile.get('/', async (c) => {
|
||||
@@ -61,7 +47,7 @@ profile.put('/', async (c) => {
|
||||
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
||||
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
||||
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
|
||||
await rebuildSite();
|
||||
await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
||||
}
|
||||
|
||||
return c.json({ ok: true, slug });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { hugoBuild, gitCommit } from '../hugo.js';
|
||||
import { buildSite, gitCommit } from '../hugo.js';
|
||||
import { syncLibrary } from '../dialog-store.js';
|
||||
|
||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||
const publish = new Hono();
|
||||
@@ -9,7 +10,9 @@ publish.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await hugoBuild({ dest: 'public', drafts: false });
|
||||
const build = await buildSite({ dest: 'public', drafts: false });
|
||||
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
||||
await syncLibrary({ force: true }).catch(() => {});
|
||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user