refactor(rate-limit): 1 req/sec cooldown, batch up to 2048 (#3)

* refactor(rate-limit): switch to 1 req/sec cooldown, batch size up to 2048

Replace per-pixel credit/token-bucket model with a simple per-user cooldown
(SET NX EX 1). Batch size is now independent of the rate limit and capped
at MAX_BATCH_SIZE = 2048.

- rate-limiter: SET NX EX replaces Lua credit script
- worker: response shape { ok: true } (no credits field)
- client: drop credit state/timer/UserInfo; uploader paces by cooldown
- tests: mock checkRateLimit; integration test exercises SET NX EX
- docs: README, system-architecture, code-standards, deployment-guide

* chore(plans): remove implemented plan directories

rplace-implementation (base build), review-fixes, and
image-importer-enhancements are all shipped. Keep plans/reports/ as
historical code-review and research references.
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-18 10:19:00 +07:00
1 parent 2d61225ee4
commit f59e55a852
29 files changed
+119 -1918

No files matched your search

+1 -17
View File
@@ -1,14 +1,12 @@
<script>
import { MAX_CREDITS, CREDIT_REGEN_RATE, MAX_BATCH_SIZE } from '../lib/constants.js';
import { MAX_BATCH_SIZE } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
import DrawToolbar from './components/DrawToolbar.svelte';
import UserInfo from './components/UserInfo.svelte';
import ImageImporter from './components/ImageImporter.svelte';
let selectedColor = $state(27); // black
let credits = $state(MAX_CREDITS);
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
let mode = $state('paint');
@@ -27,16 +25,6 @@
toastTimer = setTimeout(() => { toast = null; }, ttlMs);
}
// Client-side credit regeneration (server corrects on submit)
$effect(() => {
const interval = setInterval(() => {
if (credits < MAX_CREDITS) {
credits = Math.min(credits + CREDIT_REGEN_RATE, MAX_CREDITS);
}
}, 1000);
return () => clearInterval(interval);
});
// WebSocket connection with auto-reconnect + exponential backoff
let wsRetryDelay = 1000;
let isReconnect = false;
@@ -113,7 +101,6 @@
if (!res.ok) {
if (res.status === 429) {
const retryAfter = data?.retryAfter ?? '?';
if (typeof data?.remaining === 'number') credits = data.remaining;
showToast('error', `Rate limited — try again in ${retryAfter}s.`, 6000);
} else if (res.status === 413) {
showToast('error', 'Request too large. Reduce batch size.');
@@ -126,7 +113,6 @@
}
if (data?.ok) {
credits = data.credits;
canvasRenderer.commitPending();
showToast('info', 'Submitted', 1500);
} else {
@@ -177,7 +163,6 @@
{submitting}
/>
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
<UserInfo {credits} />
{#if !importerOpen}
<button class="import-btn" onclick={() => importerOpen = true}
@@ -192,7 +177,6 @@
getCommittedColor={(x, y) => canvasRenderer?.getCommittedColor(x, y) ?? -1}
setOverlay={(o) => canvasRenderer?.setOverlay(o)}
onClose={() => importerOpen = false}
onCredits={(c) => credits = c}
/>
{#if toast}
+3 -4
View File
@@ -1,12 +1,12 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from '../../lib/constants.js';
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
let { open, cursorPos, getCommittedColor, setOverlay, onClose } = $props();
// Source image (decoded, palette-mapped)
let fileName = $state(null);
@@ -255,7 +255,6 @@
uploader = createImageUploader({
onProgress: (p) => { placed = p; },
onCredits: (c) => onCredits?.(c),
onStatus: (s) => { statusText = s; },
onError: (e) => { errorText = e.message || String(e); },
});
@@ -288,7 +287,7 @@
const pct = $derived(total > 0 ? (placed / total) * 100 : 0);
const etaSec = $derived(
status === 'running' && total > placed
? Math.ceil((total - placed)) // ~1 pixel/sec steady state
? Math.ceil((total - placed) / MAX_BATCH_SIZE) * REQUEST_COOLDOWN_SEC
: null,
);
</script>
-80
View File
@@ -1,80 +0,0 @@
<script>
import { MAX_CREDITS } from '../../lib/constants.js';
let { credits } = $props();
let fillPercent = $derived(Math.round((credits / MAX_CREDITS) * 100));
let isFull = $derived(credits >= MAX_CREDITS);
</script>
<div class="user-info">
<div class="credits">
<span class="label">Pixels</span>
<span class="value" class:full={isFull}>{credits}</span>
<span class="max">/ {MAX_CREDITS}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
class:full={isFull}
style="width: {fillPercent}%"
></div>
</div>
</div>
<style>
.user-info {
position: fixed;
top: 16px;
left: 16px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
min-width: 120px;
}
.credits {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 0.9rem;
margin-bottom: 6px;
}
.label { color: #888; }
.value {
font-weight: bold;
color: #fbbf24;
font-variant-numeric: tabular-nums;
transition: color 0.3s;
}
.value.full { color: #4ade80; }
.max {
color: #555;
font-size: 0.8rem;
}
.bar-track {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #fbbf24;
border-radius: 2px;
transition: width 0.3s ease, background-color 0.3s;
}
.bar-fill.full {
background: #4ade80;
}
</style>
+3 -5
View File
@@ -7,11 +7,9 @@ export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;
// Batch cannot exceed credits cap — anything larger is guaranteed-rejected by rate-limit.
export const MAX_BATCH_SIZE = MAX_CREDITS;
/** Rate limiting — one request per second per user, batch size independent. */
export const REQUEST_COOLDOWN_SEC = 1;
export const MAX_BATCH_SIZE = 2048;
/** Redis keys */
export const REDIS_KEY_PREFIX = 'rplace:';
+28 -40
View File
@@ -1,22 +1,22 @@
import { MAX_BATCH_SIZE, MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from './constants.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const COOLDOWN_MS = REQUEST_COOLDOWN_SEC * 1000;
/**
* Browser-side throttled uploader for batches of pixels.
* Tracks credits locally (regen at CREDIT_REGEN_RATE/sec, capped at MAX_CREDITS),
* backs off on 429, and yields control between batches so the UI stays responsive.
* Browser-side uploader for batches of pixels.
* Server enforces 1 request per second per user (batch-size independent),
* so we send batches up to MAX_BATCH_SIZE spaced by COOLDOWN_MS. On 429 we
* back off using retryAfter. Pause/abort are cooperative.
*
* Lifecycle: create → await run(pixels) → resolves when finished or aborted.
* Call pause()/resume()/abort() from UI to steer.
*
* @param {Object} hooks
* @param {(placed: number, total: number) => void} [hooks.onProgress]
* @param {(credits: number) => void} [hooks.onCredits]
* @param {(message: string) => void} [hooks.onStatus] - human-readable status line
* @param {(error: Error) => void} [hooks.onError] - recoverable errors (uploader retries)
*/
export function createImageUploader({ onProgress, onCredits, onStatus, onError } = {}) {
export function createImageUploader({ onProgress, onStatus, onError } = {}) {
let aborted = false;
let paused = false;
@@ -24,37 +24,35 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
while (paused && !aborted) await sleep(150);
}
/** Interruptible sleep — wakes on abort; pause is handled by the outer loop. */
async function cooperativeSleep(ms) {
const end = Date.now() + ms;
while (Date.now() < end && !aborted && !paused) {
await sleep(Math.min(250, end - Date.now()));
}
}
async function run(pixels) {
aborted = false;
paused = false;
let credits = MAX_CREDITS; // optimistic; server corrects on first response
let lastUpdate = Date.now();
let placed = 0;
const total = pixels.length;
let nextSendAt = 0; // epoch ms; first send goes out immediately
while (placed < total && !aborted) {
await waitWhilePaused();
if (aborted) break;
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
// Refresh local credit estimate with elapsed regen.
const now = Date.now();
credits = Math.min(MAX_CREDITS, credits + ((now - lastUpdate) / 1000) * CREDIT_REGEN_RATE);
lastUpdate = now;
if (credits < batch.length) {
const waitSec = Math.ceil((batch.length - credits) / CREDIT_REGEN_RATE);
onStatus?.(`Waiting ${waitSec}s for credits…`);
// Split sleep so pause/abort feel responsive.
const end = Date.now() + waitSec * 1000;
while (Date.now() < end && !aborted && !paused) {
await sleep(Math.min(250, end - Date.now()));
}
continue; // re-check regen & pause on next loop
const waitMs = nextSendAt - Date.now();
if (waitMs > 0) {
onStatus?.(`Waiting ${Math.ceil(waitMs / 1000)}s…`);
await cooperativeSleep(waitMs);
continue;
}
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
onStatus?.(`Sending ${batch.length} pixels…`);
let res;
try {
res = await fetch('/api/place', {
@@ -65,18 +63,15 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
} catch (err) {
onError?.(err);
onStatus?.(`Network error, retrying in 3s…`);
await sleep(3000);
await cooperativeSleep(3000);
continue;
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const wait = Math.max(1, body.retryAfter ?? 2);
if (typeof body.remaining === 'number') onCredits?.(body.remaining);
credits = 0;
lastUpdate = Date.now();
onStatus?.(`Rate limited, waiting ${wait}s…`);
await sleep(wait * 1000);
const waitSec = Math.max(1, body.retryAfter ?? REQUEST_COOLDOWN_SEC);
onStatus?.(`Rate limited, waiting ${waitSec}s…`);
nextSendAt = Date.now() + waitSec * 1000;
continue;
}
@@ -84,19 +79,12 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
const text = await res.text().catch(() => '');
const err = new Error(`HTTP ${res.status}: ${text}`);
onError?.(err);
// Non-recoverable: stop here so user sees the error.
return { placed, total, aborted: false, error: err };
}
const body = await res.json().catch(() => ({}));
if (typeof body.credits === 'number') {
credits = body.credits;
onCredits?.(body.credits);
lastUpdate = Date.now();
}
placed += batch.length;
onProgress?.(placed, total);
nextSendAt = Date.now() + COOLDOWN_MS;
}
return { placed, total, aborted };
+15 -68
View File
@@ -1,76 +1,23 @@
import { getRedis } from './redis-client.js';
import { MAX_CREDITS, CREDIT_REGEN_RATE, REDIS_KEY_PREFIX } from './constants.js';
import { REQUEST_COOLDOWN_SEC, REDIS_KEY_PREFIX } from './constants.js';
/**
* Lua script for atomic check-and-deduct of stackable credits.
* Stores lastUpdate as ms (avoids fractional credit loss across calls).
* Returns: [allowed (0/1), remaining, retryAfterSeconds]
*/
const CREDIT_SCRIPT = `
local now = tonumber(ARGV[2])
local maxCredits = tonumber(ARGV[3])
local regen = tonumber(ARGV[4])
local count = tonumber(ARGV[1])
local lastUpdate = now
local credits = maxCredits
local data = redis.call('HGETALL', KEYS[1])
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'cr' then credits = tonumber(data[i+1]) end
end
end
local elapsedMs = now - lastUpdate
local msPerCredit = 1000 / regen
local accruedDelta = math.floor(elapsedMs / msPerCredit)
local accrued = math.min(maxCredits, credits + accruedDelta)
if accrued < count then
local deficit = count - accrued
local retryAfter = math.ceil(deficit * msPerCredit / 1000)
return {0, accrued, retryAfter}
end
-- Advance lastUpdate by exact ms used to accrue credits (preserves fractional residue).
-- When capped at maxCredits, discard residue (else lu drifts arbitrarily far back).
local newLastUpdate
if credits + accruedDelta > maxCredits then
newLastUpdate = now
else
newLastUpdate = lastUpdate + math.floor(accruedDelta * msPerCredit)
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lu', newLastUpdate, 'cr', remaining)
redis.call('EXPIRE', KEYS[1], 86400)
return {1, remaining, 0}
`;
/**
* Check and deduct credits for a user's pixel placement.
* One request per user per REQUEST_COOLDOWN_SEC.
* Batch size is independent of the cooldown — the caller validates it separately.
*
* Uses SET NX EX atomically: the first request in a window wins, subsequent
* requests return null until the key expires.
*
* @param {object} env
* @param {string} userId
* @param {number} count
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
* retryAfter is in seconds.
* @returns {Promise<{allowed: boolean, retryAfter: number}>}
*/
export async function checkAndDeductCredits(env, userId, count) {
export async function checkRateLimit(env, userId) {
const redis = getRedis(env);
const nowMs = Date.now();
const key = `${REDIS_KEY_PREFIX}credits:${userId}`;
const result = await redis.eval(
CREDIT_SCRIPT,
[key],
[count, nowMs, MAX_CREDITS, CREDIT_REGEN_RATE],
);
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: result[2],
};
const key = `${REDIS_KEY_PREFIX}cooldown:${userId}`;
const result = await redis.set(key, '1', { nx: true, ex: REQUEST_COOLDOWN_SEC });
if (result === 'OK') {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: REQUEST_COOLDOWN_SEC };
}
+5 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { getFullCanvas, setPixels } from './lib/canvas-storage.js';
import { getUserId } from './lib/get-user-id.js';
import { checkAndDeductCredits } from './lib/rate-limiter.js';
import { checkRateLimit } from './lib/rate-limiter.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
export { CanvasRoom } from './durable-objects/canvas-room.js';
@@ -67,13 +67,11 @@ app.post('/api/place', async (c) => {
}
}
// Rate limiting
// Rate limiting — 1 request per second per user, regardless of batch size.
const userId = await getUserId(c.req.raw);
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
c.env, userId, pixels.length,
);
const { allowed, retryAfter } = await checkRateLimit(c.env, userId);
if (!allowed) {
return c.json({ error: 'rate_limited', remaining, retryAfter }, 429);
return c.json({ error: 'rate_limited', retryAfter }, 429);
}
// Persist pixels (must succeed before broadcast)
@@ -95,7 +93,7 @@ app.post('/api/place', async (c) => {
broadcastTask.catch((err) => console.error('Broadcast:', err));
}
return c.json({ ok: true, credits: remaining });
return c.json({ ok: true });
});
async function broadcastPixels(env, pixels) {