Skip to content
← Todas las publicaciones
· Delta1 Labs LicensingKeyright

Asientos flotantes con Keyright: licenciar por uso simultáneo, no por máquina

La activación por nodo fija una licencia a una máquina. La licencia flotante la fija al uso simultáneo: compras 25 asientos y cualquier 25 de tus 400 instalaciones pueden ejecutarse a la vez. El mecanismo es una concesión (lease): un cliente reserva un asiento, envía latidos para conservarlo y lo devuelve al salir. Un cliente que se bloquea nunca lo devuelve, así que su concesión simplemente expira y el asiento vuelve al grupo. Así se construye la licencia de asientos concurrentes en una instancia autoalojada de Keyright con reserva por concesión, conteo en el servidor, recuperación por TTL y una gestión elegante del caso «todos los asientos en uso».

La mayoría de los modelos de licencia responden a la pregunta «¿se permite a esta copia ejecutarse en esta máquina?». La licencia flotante responde a otra distinta: «¿se están ejecutando demasiadas copias ahora mismo?». Ese cambio —de contar instalaciones a contar el uso simultáneo— es lo que permite a un estudio con 400 estaciones de trabajo comprar 25 asientos, porque en ningún momento hay más de 25 artistas realmente dentro de la aplicación. Los asientos flotan: quien arranca a continuación toma uno libre, y quien sale devuelve uno.

El mecanismo que hay debajo es una concesión (lease). Un cliente reserva un asiento antes de habilitar las funciones con licencia, mantiene la concesión viva con un latido mientras se ejecuta y devuelve el asiento al salir. El servicio de emisión cuenta las concesiones activas y se niega a repartir más de las que compraste. La parte elegante es lo que ocurre cuando un cliente no devuelve el asiento: un bloqueo, un kill -9, un cable de red arrancado. Simplemente deja de enviar latidos, su concesión expira y el asiento vuelve al grupo sin intervención. Este artículo muestra cómo construirlo en una instancia autoalojada de Keyright.

Por nodo frente a flotante

Conviene precisar qué cambia. La activación por defecto de Keyright es por nodo: ActivateAsync vincula una clave de licencia a la huella de una máquina y consume un asiento durante toda la vida de esa activación. Instala en una segunda máquina y gastas un segundo asiento, independientemente de que la primera esté siquiera encendida. Eso es exactamente lo correcto para estaciones de trabajo dedicadas, siempre de la misma persona.

La flotante invierte el vínculo. La licencia no está atada a una máquina en absoluto; está atada a un conteo de concesiones concurrentes. Puedes desplegar la aplicación en todas las máquinas del edificio. Lo que no puedes hacer es tener más de ellas ejecutando la funcionalidad con licencia al mismo tiempo de las que tienes asientos.

Activación por nodoAsiento flotante
Vinculado aLa huella de una máquinaUna concesión viva, mientras dure el uso
Se consume alActivarReservar un asiento (arranque de la app / entrada a una función)
Se libera alDesactivarDevolver al salir, o al expirar el TTL de la concesión
SobreinstalaciónBloqueada: cada instalación es un asientoPermitida: instala a lo ancho, ejecuta hasta N a la vez
Necesita el servicioEn la activación / revalidaciónDe forma continua, para conservar y contar concesiones
Ideal paraEstaciones dedicadas, tolerantes a sin conexiónLaboratorios compartidos, usuarios itinerantes, grupos de CI

El ciclo de vida de la concesión

Un asiento flotante recorre una pequeña máquina de estados: reservado → renovado (repetidamente) → devuelto, con una ruta de expiración que reconecta con «devuelto» siempre que una renovación no llega a tiempo.

Reservar asiento reservado Activo + latido renovar antes del TTL Devuelto (limpio) asiento devuelto Expirado (recuperado) TTL vencido, sin renovar cada ~TTL/3 salida bloqueo / pérdida de red La salida limpia y la muerte silenciosa terminan con el asiento de vuelta en el grupo.

Los dos estados terminales son la clave: una salida limpia y una muerte silenciosa devuelven ambas el asiento. Una es inmediata, la otra espera a que venza el TTL, pero ninguna lo pierde.

Reservar un asiento

Inicializa el cliente exactamente igual que para la licencia por nodo —la misma clave pública de solo verificación y la URL de tu instancia autoalojada— y luego reserva un asiento en lugar de (o además de) activar. AcquireSeatAsync devuelve un SeatLease: un manejador que lleva el id de la concesión, su expiración y el intervalo de renovación recomendado que el servidor calculó a partir del TTL.

var client = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "acme-studio",
    PublicKeyBase64 = config["Keyright:PublicKey"],   // verify-only, not a secret
    ServiceUrl      = config["Keyright:ServiceUrl"],   // your self-hosted instance
});

SeatLease lease;
try
{
    lease = await client.AcquireSeatAsync(userKey);
}
catch (SeatsExhaustedException ex)
{
    // Not an error condition in the app's sense — the pool is simply full.
    return Gate.Deny($"All {ex.MaxSeats} seats are in use. Try again shortly.");
}

// Fail closed: licensed features stay off until we actually hold a seat.
LicenseFeatures.Enable(lease);

Por debajo eso es un único POST. El cliente envía un id de instancia de cliente estable (uno por instalación, persistido localmente) para que el servicio reconozca una nueva reserva del mismo cliente y para que puedas mostrar «quién tiene un asiento» en una vista de administración. Una Idempotency-Key hace que la reserva sea segura de reintentar ante una respuesta perdida: una reserva que agotó el tiempo pero que de hecho tuvo éxito reproduce la misma concesión en lugar de quemar un segundo asiento:

POST /v1/seats/acquire HTTP/1.1
Host: licensing.example.com
Idempotency-Key: 5f2c-acme-studio-inst-91a7
Content-Type: application/json

{
  "product": "acme-studio",
  "licenseKey": "ACME-STU-7F3K-...",
  "clientInstanceId": "inst-91a7",
  "metadata": { "user": "r.okafor", "host": "lab-ws-14" }
}
{
  "leaseId": "lease_3b9a0c",
  "expiresAt": "2026-11-13T14:32:05Z",
  "ttlSeconds": 120,
  "renewAfterSeconds": 40,
  "seatsInUse": 24,
  "maxSeats": 25
}

Cuando el grupo está lleno, el servicio no inventa un asiento; responde 409 Conflict con un cuerpo problem+json (type: .../seats-exhausted, seatsInUse, maxSeats), que el SDK expone como SeatsExhaustedException. El conteo es autoritativo en el servidor: un cliente parcheado no puede convencerse a sí mismo de un asiento número 26.

Latir para conservar el asiento

Una concesión es deliberadamente de vida corta. Conservarla significa renovarla antes de expiresAt, lo que a la vez demuestra que el cliente está vivo y empuja la expiración hacia adelante. Renueva con un temporizador a aproximadamente renewAfterSeconds —holgadamente dentro del TTL— para que un único latido perdido no suelte el asiento.

public sealed class SeatKeepAlive : IAsyncDisposable
{
    private readonly KeyrightClient _client;
    private readonly PeriodicTimer _timer;
    private readonly CancellationTokenSource _cts = new();
    private SeatLease _lease;

    public SeatKeepAlive(KeyrightClient client, SeatLease lease)
    {
        _client = client;
        _lease  = lease;
        _timer  = new PeriodicTimer(TimeSpan.FromSeconds(lease.RenewAfterSeconds));
        _ = RunAsync();
    }

    private async Task RunAsync()
    {
        while (await _timer.WaitForNextTickAsync(_cts.Token))
        {
            try
            {
                _lease = await _client.RenewSeatAsync(_lease);   // POST /v1/seats/{id}/renew
            }
            catch (LeaseExpiredException)
            {
                // The server already reclaimed our seat (we were out too long).
                // Stop pretending to be licensed and try to re-acquire from scratch.
                LicenseFeatures.Disable();
                await _client.RecoverSeatAsync(_lease.Product, _cts.Token);
                return;
            }
            catch (TransientException)
            {
                // Network blip: keep the existing lease, let the next tick retry.
                // We still hold the seat until expiresAt regardless.
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        _cts.Cancel();
        _timer.Dispose();
        await _client.ReleaseSeatAsync(_lease);   // clean check-in on shutdown
    }
}

Dos decisiones de diseño valen su peso aquí. Primera, un intervalo de renovación bastante por debajo del TTL (un tercio es un valor por defecto sensato) te da reintentos gratis: una renovación perdida es inofensiva porque la concesión sigue siendo válida hasta el siguiente tic. Segunda, un fallo transitorio no entra en pánico ni deshabilita funciones: el cliente ya tiene el asiento hasta expiresAt, así que resiste un corte breve y solo se rinde si la concesión expira de verdad.

Cuando un cliente nunca devuelve el asiento

Este es el caso que la licencia por nodo gestiona mal y la flotante gestiona gratis. Si el proceso se bloquea, el equipo se apaga o la red desaparece, el latido se detiene. Nunca se envía una devolución. En el servidor la concesión simplemente alcanza expiresAt sin renovación, y el siguiente conteo la trata como desaparecida: el asiento vuelve al grupo. No hay ticket de soporte por asiento atascado ni paso de limpieza de administración.

El único parámetro ajustable es el retardo de recuperación: el intervalo entre la muerte de un cliente y la disponibilidad de su asiento está acotado por el TTL. Un TTL corto (digamos 120 segundos) recupera rápido pero cuesta más tráfico de latidos; un TTL más largo es más silencioso pero deja el asiento de un cliente muerto aparcado más tiempo. Elige el TTL según con qué rapidez necesitas reciclar asientos, no según cuánta charla toleras: los latidos son diminutos. Para la mayoría de las aplicaciones de escritorio, un TTL de uno a tres minutos es el punto óptimo.

Fíjate en la asimetría con una devolución limpia: ReleaseSeatAsync devuelve el asiento de inmediato, por lo que siempre deberías enlazarlo al apagado aunque la expiración acabara haciendo lo mismo. La devolución inmediata mantiene el grupo ajustado para todos los demás; la expiración es la red de seguridad, no la vía principal.

Leer el grupo en el servidor

Como el servicio es dueño del conteo, puedes exponerlo. El GetSeatPoolAsync del SDK (o un GET sencillo) informa del uso en vivo, lo que resulta útil tanto para una pista en la app de «23 de 25 asientos en uso» como para un panel de administración que liste a los titulares actuales.

SeatPool pool = await client.GetSeatPoolAsync("acme-studio");
logger.LogInformation("Floating seats: {InUse}/{Max}", pool.InUse, pool.Max);

foreach (SeatHolder h in pool.Holders)
    logger.LogInformation("  {User}@{Host} since {Since:t}, expires {Exp:t}",
        h.User, h.Host, h.AcquiredAt, h.ExpiresAt);
GET /v1/seats/pool?product=acme-studio HTTP/1.1
Host: licensing.example.com

Mostrar el conteo en vivo dentro de la aplicación es la mayor mejora individual de la experiencia de usuario, porque convierte «la app no arranca y no sé por qué» en «todos los asientos están ocupados ahora mismo, y puedo ver cuándo se libera uno».

Gestionar con elegancia «todos los asientos en uso»

Falla de forma cerrada, pero falla con amabilidad. Una reserva rechazada es un estado normal y esperado, no un error, así que no le lances una traza de pila al usuario. Captura SeatsExhaustedException, díselo con claridad y ofrece seguir intentándolo. Como los asientos se liberan en cuanto alguien sale (o su concesión expira), un bucle de reintento corto y acotado a menudo consigue un asiento en segundos.

async Task<SeatLease?> AcquireWithWaitAsync(KeyrightClient client, string key,
                                            TimeSpan budget, CancellationToken ct)
{
    var deadline = DateTimeOffset.UtcNow + budget;
    while (true)
    {
        try
        {
            return await client.AcquireSeatAsync(key, ct);
        }
        catch (SeatsExhaustedException) when (DateTimeOffset.UtcNow < deadline)
        {
            Ui.Status("All seats are in use — waiting for one to free up…");
            await Task.Delay(TimeSpan.FromSeconds(5), ct);   // bounded, with jitter in real code
        }
        catch (SeatsExhaustedException)
        {
            return null;   // gave up within budget; caller shows the final message
        }
    }
}

La diferencia de producto entre una licencia flotante que los usuarios toleran y una que les molesta está casi por completo en este manejador. Un escueto «error de licencia» hace que la concurrencia parezca un defecto; un claro «los 25 asientos están ocupados, reintentando…» hace que parezca exactamente lo que el cliente compró.

Poniéndolo todo junto

La licencia flotante es conteo, hecho duradero frente a clientes que desaparecen sin avisar. La concesión es la unidad de conteo; el latido la mantiene honesta; el TTL es lo que convierte un bloqueo en un evento que se cura solo en lugar de un asiento perdido.

  • Inicializa el cliente de Keyright como siempre, y luego AcquireSeatAsync en lugar de vincular a una máquina.
  • Trata una reserva rechazada (SeatsExhaustedException / 409) como un estado normal de grupo lleno y gestiónalo con amabilidad, con un reintento acotado.
  • Mantén la concesión viva con RenewSeatAsync en un temporizador ajustado a cerca de un tercio del TTL; tolera un único latido perdido.
  • Haz siempre ReleaseSeatAsync al apagar para una devolución inmediata; confía en la expiración por TTL como red de seguridad ante bloqueos.
  • Deja que el servidor sea dueño del conteo: la aplicación, el máximo y la expiración son todos autoritativos en el servicio de emisión.
  • Elige el TTL según con qué rapidez necesitas recuperar asientos muertos; más corto significa reciclado más rápido a cambio de algo más de tráfico de latidos.
  • Usa la activación por nodo para máquinas dedicadas o tolerantes a sin conexión; usa la flotante para el uso compartido, itinerante e intermitente.

Compra 25 asientos, despliega en 400 máquinas y deja que el grupo resuelva quién está trabajando de verdad ahora mismo. Esa es toda la promesa de la licencia flotante, y con la reserva por concesión, el conteo en el servidor y la recuperación por TTL, se sostiene incluso cuando un cliente se marcha en mitad de la sesión y nunca se despide.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.