Latidos de licencia y recolección de contratos: contar asientos concurrentes cuando los clientes se caen
Una licencia flotante vende N asientos compartidos por un equipo, así que el servidor tiene que saber cuántos están en uso *ahora mismo*. Es fácil hasta que un cliente muere de la forma incorrecta —una caída, una VM eliminada, un cable de red arrancado— y nunca devuelve su asiento. Lleva la cuenta en el checkout y el asiento queda perdido para siempre; tu cliente paga por diez y puede ejecutar seis. La solución es dejar de confiar en checkout/checkin como un par emparejado y tratar cada asiento como un contrato (lease) con un tiempo de vida que el cliente debe mantener con latidos. Aquí tienes el protocolo de contrato y latido en Keyright: el contrato firmado de TTL corto, el bucle de renovación en el cliente, el recolector (reaper) que reclama asientos muertos en el servidor, y las reglas de reloj y gracia que mantienen la cuenta honesta sin castigar una red inestable.
Una licencia flotante (concurrente) vende un número de asientos —digamos diez— que un equipo comparte. Cualquiera puede tomar un asiento al arrancar la app, y a la undécima persona que lo intente se le dice que espere. Todo el modelo descansa en un número del lado del servidor: ¿cuántos asientos están en uso ahora mismo? Equivócate en ese número a favor del cliente y regalas asientos; equivócate a tu favor y bloqueas a gente que pagó.
La implementación ingenua se equivoca casi de inmediato. Incrementas un contador en el checkout y lo decrementas en el checkin, y funciona de maravilla en una demo donde cada app se cierra limpiamente. Entonces ocurre la primera caída real —un StackOverflowException que tumba el proceso sin desenrollar, una VM que el equipo de operaciones pausa y descarta, un desarrollador que cierra la tapa y conduce a casa— y ese asiento no se devuelve nunca. El contador nunca vuelve a bajar. Hazlo unas docenas de veces a lo largo de un trimestre y una licencia de diez asientos queda silenciosamente encallada en dos asientos usables, y el ticket de soporte dice “compramos diez, ¿por qué solo dos podemos ejecutarlo?”.
Esta entrada trata de la solución: dejar de tratar checkout y checkin como un par emparejado del que puedes fiarte, y tratar cada asiento como un contrato (lease) con un tiempo de vida que el cliente tiene que mantener con latidos. Un asiento vuelve cuando el cliente lo libera o cuando su contrato caduca, lo que ocurra primero. La caducidad es lo que hace que la cuenta se autorregule cuando un cliente muere de la forma incorrecta.
El contrato es un claim firmado con una caducidad
Un contrato de Keyright no es un booleano “tienes un asiento”. Es un pequeño conjunto de claims que el servicio emisor firma —el mismo esquema de firma que protege cualquier otro token de Keyright—, así que el cliente puede leerlo, cachearlo y demostrarlo, pero no puede alterarlo. Los campos que importan para la concurrencia son el id de licencia, un id de asiento asignado por el servidor, el titular (para que una UI pueda mostrar quién tiene los otros nueve asientos) y dos marcas de tiempo: cuándo se emitió el contrato y cuándo caduca.
public sealed record SeatLease
{
public required string LicenseId { get; init; }
public required string SeatId { get; init; } // server-assigned, unique per live seat
public required string Holder { get; init; } // user or machine label, for display
public required DateTimeOffset IssuedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; } // IssuedAt + policy.Ttl
// Signed by the issuing service; verified on the client against the embedded public key.
public required string Signature { get; init; }
public bool IsLive(DateTimeOffset now, TimeSpan skew) => now <= ExpiresAt + skew;
}
El cliente nunca calcula ExpiresAt por sí mismo y nunca confía en su propio reloj para extenderlo: la caducidad es lo que el servidor firmó. El único uso local del reloj es la dirección honesta: decidir que el contrato ha caducado para que el cliente deje de actuar sobre él. Volveremos al desfase del reloj, porque es el único lugar donde este diseño puede morderte.
Checkout: entrega un asiento solo si hay uno libre
El checkout es la única operación que debe serializarse por licencia, porque es donde dos clientes pueden competir por el último asiento. El servidor cuenta los contratos que siguen vivos, rechaza si el pool está lleno, y si no, acuña un contrato nuevo con un nuevo id de asiento y lo devuelve firmado.
public sealed class SeatService
{
private readonly ILeaseStore _store; // persistence is an implementation detail
private readonly ILeaseSigner _signer; // holds the private key; server-only
private readonly TimeProvider _clock;
public async Task<CheckoutResult> CheckoutAsync(
string licenseId, string holder, SeatPolicy policy, CancellationToken ct)
{
// Serialize per license so two callers cannot both see "one seat free".
await using var _ = await _store.LockLicenseAsync(licenseId, ct);
var now = _clock.GetUtcNow();
var live = await _store.CountLiveLeasesAsync(licenseId, now, policy.Skew, ct);
if (live >= policy.SeatCount)
return CheckoutResult.NoSeatsAvailable(policy.SeatCount);
var lease = new SeatLease
{
LicenseId = licenseId,
SeatId = Guid.NewGuid().ToString("N"),
Holder = holder,
IssuedAt = now,
ExpiresAt = now + policy.Ttl,
Signature = "" // filled by the signer below
};
var signed = _signer.Sign(lease);
await _store.UpsertAsync(signed, ct);
return CheckoutResult.Granted(signed);
}
}
Dos detalles deciden si la cuenta se mantiene honesta. Primero, CountLiveLeasesAsync debe contar por caducidad, no por una bandera de estado: un contrato está vivo si now <= ExpiresAt + skew, punto. Si mantienes una columna “activo” aparte y olvidas limpiarla, vuelves al problema del contador perdido. Segundo, el bloqueo por licencia es obligatorio. Sin él, dos clientes que hacen checkout del décimo asiento en el mismo milisegundo leen ambos live == 9, ambos pasan la comprobación, y tienes once asientos fuera contra una licencia de diez. El ámbito del bloqueo es una licencia, retenido durante microsegundos, así que no serializa todo tu servicio.
Latido: el cliente renueva su propio contrato
Una vez que un cliente tiene un contrato, su única tarea es volver antes de la caducidad y pedir al servidor que empuje la caducidad hacia adelante. El servidor vuelve a comprobar que el asiento aún existe (podría haber sido revocado o recolectado) y, si es así, emite un contrato nuevo para el mismo id de asiento con una caducidad posterior.
public async Task<HeartbeatResult> HeartbeatAsync(
string licenseId, string seatId, SeatPolicy policy, CancellationToken ct)
{
await using var _ = await _store.LockLicenseAsync(licenseId, ct);
var now = _clock.GetUtcNow();
var existing = await _store.FindAsync(licenseId, seatId, ct);
// Seat was revoked or already reaped — the client must check out again.
if (existing is null || !existing.IsLive(now, policy.Skew))
return HeartbeatResult.SeatLost();
var renewed = _signer.Sign(existing with
{
IssuedAt = now,
ExpiresAt = now + policy.Ttl
});
await _store.UpsertAsync(renewed, ct);
return HeartbeatResult.Renewed(renewed);
}
Observa lo que el latido no hace: nunca incrementa la cuenta de asientos. Solo renueva un asiento que el cliente ya tiene, así que no hay competencia que serializar contra el tamaño del pool: el bloqueo aquí es solo para evitar que una renovación colisione con una recolección de la misma fila. Un latido para un asiento que ya ha sido recolectado devuelve SeatLost, y la respuesta correcta del cliente es ejecutar checkout de nuevo, que le conseguirá un asiento fresco o le dirá que el pool está lleno.
En el cliente, el latido es un bucle en segundo plano que se dispara a una fracción del TTL para que una única petición caída no sea fatal. Un tercio del TTL te da dos o tres intentos antes de que el contrato caduque. Añade un poco de jitter para que mil clientes que arrancaron todos tras un despliegue no laten al unísono.
public sealed class LeaseKeeper : BackgroundService
{
private readonly KeyrightClient _client;
private readonly LeaseState _state; // holds the current signed lease for enforcement
private readonly TimeProvider _clock;
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
var lease = _state.Current;
// Beat at ~1/3 TTL, with jitter, measured from this lease's own window.
var window = lease.ExpiresAt - lease.IssuedAt;
var baseDelay = window / 3;
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 2_000));
await Task.Delay(baseDelay + jitter, stop);
try
{
var result = await _client.HeartbeatAsync(lease.LicenseId, lease.SeatId, stop);
if (result.Renewed)
_state.Replace(result.Lease); // new expiry, enforcement continues
else
await ReacquireOrDegradeAsync(stop); // SeatLost: try checkout again
}
catch (HttpRequestException)
{
// Transient: do nothing. The lease is still valid until ExpiresAt; we will
// retry on the next tick. Only a *lapsed* lease should degrade the app.
}
}
}
}
El catch es todo el propósito del TTL. Un latido fallido no es un desalojo: el contrato que el cliente ya tiene es válido hasta su ExpiresAt firmado, así que un parpadeo de red de treinta segundos es invisible. El cliente solo degrada (modo de solo lectura, un banner de “asiento perdido”, lo que haga tu producto) cuando el contrato que tiene ha caducado de verdad y una readquisición ha fallado. Esa separación —fallo transitorio frente a caducidad genuina— es lo que impide que un Wi-Fi inestable de cafetería eche a un usuario que paga en mitad de una edición.
El recolector: reclama lo que los clientes no devolvieron
La caducidad del contrato es necesaria pero no suficiente. Un cliente caído deja de latir, y su contrato se leerá como caducado para quien lo compruebe, pero nada lo comprueba hasta que ocurre el siguiente checkout. Si el pool está tranquilo, un asiento muerto puede quedarse en el almacén pareciendo ocupado mucho tiempo, y CountLiveLeasesAsync ya lo excluye (cuenta por caducidad), así que la corrección está bien. Lo que pierdes sin un recolector es orden y observabilidad: las filas obsoletas se acumulan, y un panel que lista “titulares actuales” muestra fantasmas.
El recolector es un barrido periódico que borra los contratos cuya caducidad (más el desfase, más una gracia opcional) está bien pasada.
public sealed class LeaseReaper(ILeaseStore store, TimeProvider clock) : BackgroundService
{
private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
var now = clock.GetUtcNow();
// Reap only leases that are past expiry by a margin, so a client whose heartbeat
// is a few seconds late is never reaped out from under itself.
var cutoff = now - LeasePolicy.ReapGrace; // e.g. expiry + 15s
var reaped = await store.DeleteExpiredAsync(before: cutoff, stop);
if (reaped > 0)
Log.SeatsReclaimed(reaped);
await Task.Delay(SweepInterval, stop);
}
}
}
El recolector y CountLiveLeasesAsync deben coincidir en la aritmética, o tienes el bug más desagradable de todo este diseño: un asiento que el contador trata como libre pero que el recolector aún no ha borrado, o viceversa. Mantén la regla en un solo lugar —un contrato está vivo si y solo si now <= ExpiresAt + skew— y haz que tanto el contador como el recolector llamen al mismo predicado. El ReapGrace del recolector es puramente un margen de seguridad sobre el borrado; debe ser mayor que el desfase que permite el contador, para que el recolector nunca borre una fila que el contador aún contaría. Borra con demasiada avidez y un cliente cuyo latido llega tres segundos tarde encuentra su asiento desaparecido y tiene que rehacer checkout sin motivo.
El desfase del reloj es lo único que te morderá
Todas las marcas de tiempo aquí son del servidor. El cliente lee ExpiresAt para saber cuándo parar, pero lo compara con su propio reloj, y los relojes de cliente están mal —a veces por minutos, ocasionalmente por horas, y un usuario decidido puede poner el suyo en lo que sea—. De ahí siguen dos modos de fallo.
Si el reloj del cliente va lento (atrasado respecto al servidor), cree que el contrato sigue vivo después de que el servidor lo considere caducado. Inofensivo para el conteo —el servidor recolecta el asiento a su hora sin importar lo que el cliente crea—, pero significa que un cliente puede actuar brevemente sobre un contrato que el servidor ya reclamó. Mantén estrecha la ventana de aplicación y esto es un efecto sub-TTL que el siguiente latido corrige.
Si el reloj del cliente va rápido (adelantado respecto al servidor), cree que el contrato caducó antes y degrada o readquiere demasiado pronto. Molesto pero no es un agujero de licenciamiento: el usuario no pierde nada de lo que pagó; solo genera un checkout extra.
La tolerancia de desfase (policy.Skew) existe para absorber los pocos segundos normales de deriva de modo que ninguno de los dos lados oscile. Lo que no debe hacer es convertirse en una puerta trasera: nunca dejes que el reloj del cliente extienda un contrato. La caducidad la firma el servidor, el servidor recolecta con su propio reloj, y el reloj del cliente solo decide ceder un asiento antes de tiempo. Esa asimetría —el cliente puede liberar antes, solo el servidor puede extender— es lo que impide que “atrasa tu reloj” sea un exploit de acaparamiento de asientos. Es la misma disciplina que derrota la reversión del reloj de prueba, aplicada a la concurrencia.
Lo que realmente despliegas
Las piezas son pequeñas, y la mayor parte de la corrección vive en dos reglas que puedes enunciar en una frase cada una. Un asiento está vivo si y solo si now <= ExpiresAt + skew, y tanto el contador como el recolector obedecen ese único predicado. El cliente puede liberar un asiento antes de tiempo pero nunca puede extender uno: solo lo hace la firma del servidor. Acierta en esas dos y el resto es fontanería: un registro de contrato firmado, un checkout que bloquea por licencia y cuenta por caducidad, un latido que renueva sin tocar la cuenta, un guardián en segundo plano en el cliente que distingue una petición caída de un contrato caducado, y un recolector que ordena sobre un margen. La recompensa es una cuenta de asientos concurrentes que se autorregula: un cliente que compra diez asientos siempre puede ejecutar diez, un cliente caído le cuesta un asiento durante como mucho un TTL, y nadie tiene que abrir un ticket porque el licenciamiento se comió sus asientos en silencio.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.