Bound and deduplicate server image rendering per account

This commit is contained in:
syntaxbullet
2026-09-08 16:18:05 +02:00
parent e3e07404f8
commit ba049efdc8
8 changed files with 101 additions and 12 deletions

View File

@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
]; ];
const reply = message(c, content, [row(...controls)], false); const reply = message(c, content, [row(...controls)], false);
c.defer(async () => { c.defer(async () => {
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; } try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; } catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
}); });
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} }; return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
import '../sharing/live-card-image'; import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme'; import { themePalette, type Theme } from '../lib/theme';
@@ -7,7 +8,10 @@ export type CardRow = { label: string; detail: string; value?: string; ratio?: n
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] }; export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 }); export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
/** Fixed-size rows and text keep every page readable, including very large accounts. */ /** Fixed-size rows and text keep every page readable, including very large accounts. */
export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') { export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('command', userId, data, theme, () => draw(data, theme));
}
async function draw(data: CommandCard, theme: Theme) {
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.'); if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d'); const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
const p = themePalette[theme]; const p = themePalette[theme];
@@ -61,5 +65,6 @@ export async function renderCommandCard(data: CommandCard, theme: Theme = 'light
rect(64, height - 92, 1072, 1, p.rule); rect(64, height - 92, 1072, 1, p.rule);
text(data.note, 64, height - 48, 21, 860, p.muted); text(data.note, 64, height - 48, 21, 860, p.muted);
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted); text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
return new Uint8Array(await canvas.encode('png')); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract. // Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image'; import { type LiveImageData } from '../sharing/live-card-image';
@@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 }; export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */ /** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') { export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('progress', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const habit = data.habits[0]; const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.'); if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height); const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
text(item.label, x + 36, 772, 21, 230, palette.muted); text(item.label, x + 36, 772, 21, 230, palette.muted);
}); });
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true); text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
return new Uint8Array(await canvas.encode('png')); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -24,7 +24,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null }; return { ...result, avatarUrl: null };
} }
async function render(c: Context, data: ControlData, source = imageData(c, data)) { async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light'); return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
} }
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; } function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply { function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
@@ -87,7 +87,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const source = imageData(c, data); const source = imageData(c, data);
const image = await render(c, data, source); const image = await render(c, data, source);
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.'); if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' })); const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false)); return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
} }
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light'); const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');

View File

@@ -0,0 +1,27 @@
import { expect, test } from 'bun:test';
import { createRenderQueue, RenderBusyError } from './queue';
const result = new Uint8Array([1, 2, 3]);
test('render queue bounds global concurrency and shares only the same owner and content', async () => {
const queue = createRenderQueue({ concurrency: 2, maxPending: 4, maxPerUser: 2 });
let active = 0, peak = 0, calls = 0;
const run = async () => { calls++; active++; peak = Math.max(peak, active); await Bun.sleep(5); active--; return result; };
const first = queue('alice', 'one', run);
expect(queue('alice', 'one', run)).toBe(first);
const all = [first, queue('alice', 'two', run), queue('bob', 'one', run), queue('carol', 'one', run)];
await Promise.all(all); expect(calls).toBe(4); expect(peak).toBe(2);
expect(await queue('alice', 'one', run)).toBe(result); expect(calls).toBe(5);
});
test('render admission enforces per-user and global capacity and recovers after failures', async () => {
const queue = createRenderQueue({ concurrency: 1, maxPending: 2, maxPerUser: 1 });
let release!: () => void;
const first = queue('alice', 'a', async () => { await new Promise<void>(resolve => { release = resolve; }); throw new Error('render failed'); });
const failure = first.catch(error => error);
await expect(queue('alice', 'b', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
const second = queue('bob', 'b', async () => result);
await expect(queue('carol', 'c', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
while (!release) await Bun.sleep(1);
release(); expect((await failure).message).toBe('render failed'); await second;
expect(await queue('alice', 'a', async () => result)).toBe(result);
});

42
src/rendering/queue.ts Normal file
View File

@@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';
import { ApiError } from '../habits/service';
export class RenderBusyError extends ApiError {
constructor() { super(429, 'Image rendering is busy. Try again shortly.'); }
}
type Job = { run: () => Promise<Uint8Array>; resolve: (image: Uint8Array) => void; reject: (reason: unknown) => void };
/** One process-wide admission boundary for every server canvas renderer. */
export function createRenderQueue({ concurrency = 2, maxPending = 16, maxPerUser = 2 } = {}) {
const pending = new Map<string, Promise<Uint8Array>>();
const owners = new Map<string, number>();
const waiting: Job[] = [];
let active = 0;
function pump() {
while (active < concurrency && waiting.length) {
const job = waiting.shift()!; active++;
// Yield before drawing so a burst of callers cannot monopolize this turn.
setTimeout(() => {
void Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => { active--; pump(); });
}, 0);
}
}
return (userId: string, key: string, run: Job['run']): Promise<Uint8Array> => {
const scopedKey = JSON.stringify([userId, key]);
const existing = pending.get(scopedKey);
if (existing) return existing;
if (pending.size >= maxPending || (owners.get(userId) ?? 0) >= maxPerUser) return Promise.reject(new RenderBusyError());
owners.set(userId, (owners.get(userId) ?? 0) + 1);
const work = new Promise<Uint8Array>((resolve, reject) => { waiting.push({ run, resolve, reject }); }).finally(() => {
pending.delete(scopedKey);
const count = owners.get(userId)! - 1;
if (count) owners.set(userId, count); else owners.delete(userId);
});
pending.set(scopedKey, work); pump();
return work;
};
}
const schedule = createRenderQueue();
export function renderImage(kind: string, userId: string, data: unknown, theme: string, run: Job['run']) {
const hash = createHash('sha256').update(JSON.stringify([kind, data, theme])).digest('hex');
return schedule(userId, hash, run);
}

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { cachedAvatar } from './avatar-cache'; import { cachedAvatar } from './avatar-cache';
import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
@@ -48,7 +49,10 @@ export function liveCardLayout(count: number) {
} }
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */ /** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
export async function renderLiveCard(data: LiveImageData, theme: Theme) { export function renderLiveCard(data: LiveImageData, theme: Theme, userId = 'unscoped') {
return renderImage('live', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const layout = liveCardLayout(data.habits.length); const layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height); const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -127,5 +131,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
rect(64, 1010, 1792, 1, colors.rule); rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted); text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted); ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
return canvas.encode('png'); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -1,3 +1,4 @@
import { RenderBusyError } from '../rendering/queue';
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
guildId = channel.guild_id; guildId = channel.guild_id;
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; } } catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
} }
const png = await renderLiveCard(data, current.theme); const png = await renderLiveCard(data, current.theme, id);
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; } if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; }
// A stop or account deletion during image generation cancels the outbound edit. // A stop or account deletion during image generation cancels the outbound edit.
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return; if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
@@ -139,7 +140,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
clearTimeout(timers.get(id)); timers.delete(id); clearTimeout(timers.get(id)); timers.delete(id);
const existing = running.get(id); const existing = running.get(id);
if (existing) return existing; if (existing) return existing;
const work = queue.then(() => deliver(id)).catch(() => { const work = queue.then(() => deliver(id)).catch(error => {
if (error instanceof RenderBusyError && !stopped && ['active', 'sending'].includes(row(id)?.status ?? '')) {
update(id, { dirty: true, retryAt: now() + 1000, error: 'Image rendering is busy; update queued.' });
schedule(id, 1000); return;
}
if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); } if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
}).finally(() => running.delete(id)); }).finally(() => running.delete(id));
queue = work; running.set(id, work); return work; queue = work; running.set(id, work); return work;
@@ -152,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/', c => c.json(state(c.get('user').id))); app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => { app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light'; const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme); const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme, c.get('user').id);
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"'); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image)); return c.body(new Uint8Array(image));
}); });