mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 18:19:15 +00:00
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:
1 parent
2d61225ee4
commit
f59e55a852
29 files changed
+119
-1918
No files matched your search
+1
-17
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user