Seis descargas contadas como cuatro
Tengo una herramienta pequeña con un plan gratuito: cinco descargas al día en calidad original y, a partir de ahí, un plan de pago. La primera versión guardaba la cuenta diaria en Workers KV. Descargué seis imágenes seguidas y el contador marcaba cuatro.
No había nada roto. KV estaba haciendo lo que su documentación dice que hace, y yo lo había puesto en el único sitio donde ese comportamiento cuesta dinero.
1. Qué hacía la primera versión
En esencia:
const n = Number(await env.KV.get(key)) || 0;
if (n >= LIMIT) return rejected();
await env.KV.put(key, String(n + 1));
Leer, comparar, escribir. Fallan dos cosas.
La primera es que los tres pasos no son una sola operación. Dos peticiones pueden leer el mismo n, pasar las dos la comprobación y escribir las dos n + 1. Un incremento se pierde.
La segunda es que las lecturas van por caché. La documentación de Cloudflare dice que un cambio suele verse al instante en la ubicación donde se hizo, que eso no está garantizado y que en otras ubicaciones puede tardar 60 segundos o más. Una lectura justo después de una escritura puede devolver el valor anterior, así que la cuenta se queda atrás aunque las peticiones lleguen una detrás de otra. Así es como seis se convirtieron en cuatro.
La figura enseña el peor caso de esa misma carrera, con doce peticiones a la vez.
2. Por qué aquí importaba
Un contador que se equivoca en dos es inofensivo casi en cualquier sitio. Este es la frontera entre el plan gratuito y el de pago. Cada descarga que no cuenta es producto regalado, y quien haga clic lo bastante rápido se lleva más de lo que marca el límite.
3. Un objeto por visitante
El contador es ahora un Durable Object. Cada uno trabaja en un solo hilo y tiene su propio almacenamiento con consistencia fuerte. Hay uno por visitante, identificado por un hash truncado de la IP, así que todas las peticiones del mismo visitante llegan al mismo objeto y se atienden de una en una.
Lo esencial:
let s = (await this.state.storage.get("s")) || { day, n: 0 };
if (s.day !== day) s = { day, n: 0 };
let allowed = true;
if (s.n < limit) {
s.n++;
await this.state.storage.put("s", s);
} else {
allowed = false; // lo que se rechaza no se cuenta
}
Parece el mismo leer, comparar y escribir, con una diferencia: mientras el objeto espera a su propio almacenamiento, no se le entrega ninguna otra petición. Comprobar y sumar son una sola operación.
Dos detalles. Todo el estado es una única clave que se reinicia al cambiar el día, así que no hay nada que limpiar ni caducidades que programar. Y un proyecto de Pages puede usar Durable Objects pero no puede definirlos, de modo que la clase vive en un Worker aparte, de 43 líneas, enlazado con la web.
La prueba: 12 peticiones simultáneas, 5 permitidas, 7 rechazadas, contador en 5.
4. El mismo fallo, con un cuadro de por medio
También vendo cuadros, y cada uno existe una sola vez. El stock es siempre 1.
La marca de vendido estaba en KV. Lancé cinco compras simultáneas del mismo cuadro y las cinco salieron adelante.
El arreglo es la misma idea, con más en juego. Antes de crear la sesión de Stripe, el Worker pide a un único Durable Object que reserve todos los cuadros del pedido, todos o ninguno. Un segundo comprador recibe un 409 y un mensaje que dice que ese cuadro se está comprando y cuándo volverá a estar libre. Con el candado, la misma prueba da un éxito y cuatro 409.
La reserva dura siete minutos, y eso deja un hueco: una sesión de Stripe Checkout no puede caducar antes de 30 minutos desde que se crea, así que la página de pago del primer comprador puede vivir más que la reserva. Cuando un segundo comprador reserva pasados esos siete minutos, el Worker caduca en Stripe la primera sesión antes de abrir la nueva. Si Stripe responde que esa sesión ya está completada, la segunda compra se rechaza como vendida.
Los eventos de Stripe cierran el círculo. checkout.session.expired libera la reserva al momento. checkout.session.completed la convierte en «vendido» durante 24 horas, lo que cubre el tiempo hasta que la marca de KV se ha propagado y el catálogo se vuelve a publicar.
5. Dónde he dejado KV
No todo necesita esto.
Las suscripciones siguen en KV. Cuando alguien se da de baja, el servidor deja de validar la licencia unos 45 segundos después, y la extensión del navegador se entera en su siguiente revalidación, como mucho doce horas más tarde. Es el precio de no preguntar al servidor en cada descarga, y lo acepto.
Las marcas de vendido de la galería también siguen en KV, como capas posteriores detrás del candado. Esas llamadas a KV son de mejor esfuerzo: si KV falla, se registra el error y la venta sigue adelante con las comprobaciones restantes. El candado falla igual, dejando pasar. Una avería en una red de seguridad no debe tumbar la tienda.
6. Cómo lo decido ahora
Dos preguntas.
¿El valor se lee y después se escribe en función de lo leído? Si es así, KV no puede hacerlo de forma segura, por rara que parezca la coincidencia.
¿Qué cuesta una lectura desfasada? Si la respuesta es dinero o algo que existe una sola vez, va en un Durable Object. Si la respuesta es que alguien ve un dato antiguo durante un minuto, KV es más simple y sus lecturas se sirven desde caché en el borde.