Skip to content
← Tous les articles
· Delta1 Labs Licensing.NETGuide

Battements de licence et récupération des baux : compter les sièges concurrents quand les clients plantent

Une licence flottante vend N sièges partagés par une équipe, donc le serveur doit savoir combien sont utilisés *à cet instant*. C'est facile jusqu'à ce qu'un client meure de la mauvaise façon — un plantage, une VM supprimée, un câble réseau arraché — sans jamais rendre son siège. Comptez à la prise (checkout) et le siège est perdu pour toujours ; votre client paie pour dix et peut en exécuter six. La solution est de cesser de se fier au couple checkout/checkin et de traiter chaque siège comme un bail (lease) doté d'une durée de vie que le client doit entretenir par des battements (heartbeats). Voici le protocole bail-et-battement dans Keyright : le bail signé à TTL court, la boucle de renouvellement côté client, le récupérateur (reaper) qui récupère les sièges morts côté serveur, et les règles d'horloge et de grâce qui gardent le compte honnête sans punir un réseau instable.

Une licence flottante (concurrente) vend un nombre de sièges — disons dix — qu”une équipe partage. N”importe qui peut prendre un siège au démarrage de l”appli, et à la onzième personne qui essaie on dit d”attendre. Tout le modèle repose sur un seul nombre côté serveur : combien de sièges sont utilisés à cet instant ? Trompez-vous sur ce nombre en faveur du client et vous donnez des sièges ; trompez-vous en votre faveur et vous bloquez des gens qui ont payé.

L”implémentation naïve se trompe presque aussitôt. Vous incrémentez un compteur au checkout et le décrémentez au checkin, et cela marche à merveille dans une démo où chaque appli se ferme proprement. Puis survient le premier vrai plantage — un StackOverflowException qui abat le processus sans dérouler, une VM que l”équipe ops met en pause et jette, un développeur qui rabat le capot et rentre en voiture — et ce siège n”est jamais rendu. Le compteur ne redescend jamais. Faites-le quelques dizaines de fois sur un trimestre et une licence à dix sièges s”échoue silencieusement à deux sièges utilisables, et le ticket de support dit « on en a acheté dix, pourquoi seulement deux d”entre nous peuvent l”exécuter ? ».

Ce billet traite de la solution : cesser de traiter checkout et checkin comme un couple fiable, et traiter chaque siège comme un bail (lease) doté d”une durée de vie que le client doit entretenir par des battements (heartbeats). Un siège revient quand le client le libère ou quand son bail expire, selon ce qui arrive en premier. L”expiration est ce qui rend le compte auto-réparateur quand un client meurt de la mauvaise façon.

client sain — les battements gardent le bail en vie, le checkin propre le libère plus tôtcheckoutbattementbattementcheckin → siège libéréclient planté — plus de battements, le bail expire, le récupérateur récupère le siègecheckoutbattementplantagele bail expirebalayage du récupérateur → siège récupéré

Le bail est une revendication signée avec une expiration

Un bail Keyright n”est pas un booléen « vous avez un siège ». C”est un petit ensemble de revendications que le service d”émission signe — le même schéma de signature qui protège tout autre jeton Keyright —, donc le client peut le lire, le mettre en cache et le prouver, mais ne peut pas l”altérer. Les champs qui comptent pour la concurrence sont l”id de licence, un id de siège attribué par le serveur, le détenteur (pour qu”une UI puisse montrer qui a les neuf autres sièges) et deux horodatages : quand le bail a été émis et quand il expire.

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;
}

Le client ne calcule jamais ExpiresAt lui-même et ne se fie jamais à sa propre horloge pour l”étendre : l”expiration est ce que le serveur a signé. Le seul usage local de l”horloge est la direction honnête : décider que le bail a expiré pour que le client cesse d”agir dessus. Nous reviendrons au décalage d”horloge, car c”est le seul endroit où ce design peut vous mordre.

Checkout : ne donnez un siège que s”il y en a un de libre

Le checkout est la seule opération qui doit être sérialisée par licence, car c”est là que deux clients peuvent se disputer le dernier siège. Le serveur compte les baux encore vivants, refuse si le pool est plein, sinon frappe un bail neuf avec un nouvel id de siège et le renvoie signé.

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);
    }
}

Deux détails décident si le compte reste honnête. D”abord, CountLiveLeasesAsync doit compter par expiration, pas par un drapeau d”état : un bail est vivant si now <= ExpiresAt + skew, point. Si vous gardez une colonne « actif » à part et oubliez de l”effacer, vous retombez dans le problème du compteur perdu. Ensuite, le verrou par licence est obligatoire. Sans lui, deux clients qui font checkout du dixième siège à la même milliseconde lisent tous deux live == 9, passent tous deux la vérification, et vous avez onze sièges sortis contre une licence à dix. La portée du verrou est une licence, tenue pendant des microsecondes, donc elle ne sérialise pas tout votre service.

Battement : le client renouvelle son propre bail

Une fois qu”un client détient un bail, sa seule tâche est de revenir avant l”expiration et de demander au serveur de pousser l”expiration vers l”avant. Le serveur revérifie que le siège existe encore (il a pu être révoqué ou récupéré) et, si oui, émet un bail neuf pour le même id de siège avec une expiration plus tardive.

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);
}

Remarquez ce que le battement ne fait pas : il n”incrémente jamais le compte de sièges. Il ne renouvelle qu”un siège que le client détient déjà, donc il n”y a pas de course à sérialiser contre la taille du pool : le verrou ici sert juste à éviter qu”un renouvellement ne percute une récupération de la même ligne. Un battement pour un siège déjà récupéré renvoie SeatLost, et la réponse correcte du client est de relancer le checkout, qui lui obtiendra un siège neuf ou lui dira que le pool est plein.

Côté client, le battement est une boucle d”arrière-plan qui se déclenche à une fraction du TTL pour qu”une seule requête perdue ne soit pas fatale. Un tiers du TTL vous donne deux ou trois tentatives avant l”expiration du bail. Ajoutez un peu de jitter pour que mille clients tous démarrés après un déploiement ne battent pas à l”unisson.

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.
            }
        }
    }
}

Le catch est toute la raison d”être du TTL. Un battement échoué n”est pas une éjection : le bail que le client détient déjà est valide jusqu”à son ExpiresAt signé, donc un raté réseau de trente secondes est invisible. Le client ne se dégrade (mode lecture seule, une bannière « siège perdu », ce que fait votre produit) que lorsque le bail qu”il détient a réellement expiré et qu”une ré-acquisition a échoué. Cette séparation — échec transitoire contre expiration réelle — est ce qui empêche un Wi-Fi de café instable d”éjecter un utilisateur payant en pleine édition.

Le récupérateur : récupère ce que les clients n”ont pas rendu

L”expiration du bail est nécessaire mais pas suffisante. Un client planté cesse de battre, et son bail se lira comme expiré pour quiconque le vérifie — mais rien ne le vérifie jusqu”au prochain checkout. Si le pool est calme, un siège mort peut rester dans le magasin à paraître occupé longtemps, et CountLiveLeasesAsync l”exclut déjà (il compte par expiration), donc la correction est assurée. Ce que vous perdez sans récupérateur, c”est l”ordre et l”observabilité : les lignes périmées s”accumulent, et un tableau de bord qui liste les « détenteurs actuels » montre des fantômes.

Le récupérateur est un balayage périodique qui supprime les baux dont l”expiration (plus le décalage, plus une grâce optionnelle) est bien passée.

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);
        }
    }
}

Le récupérateur et CountLiveLeasesAsync doivent s”accorder sur l”arithmétique, sinon vous avez le bug le plus désagréable de tout ce design : un siège que le compteur traite comme libre mais que le récupérateur n”a pas encore supprimé, ou l”inverse. Gardez la règle à un seul endroit — un bail est vivant si et seulement si now <= ExpiresAt + skew — et faites appeler le même prédicat par le compteur et par le récupérateur. Le ReapGrace du récupérateur est purement une marge de sécurité sur la suppression ; il doit être plus grand que le décalage que le compteur autorise, pour que le récupérateur ne supprime jamais une ligne que le compteur compterait encore. Supprimez trop vite et un client dont le battement arrive trois secondes en retard trouve son siège disparu et doit refaire un checkout sans raison.

T0émisT0+TTLexpire+décalagele compteur cesse de compter+grâcele récupérateur supprimevivant · compté · renouvelablemarge de sécuritérécupéré

Le décalage d”horloge est la seule chose qui vous mordra

Tous les horodatages ici sont ceux du serveur. Le client lit ExpiresAt pour savoir quand s”arrêter, mais il le compare à sa propre horloge, et les horloges de client sont fausses — parfois de minutes, occasionnellement d”heures, et un utilisateur déterminé peut régler la sienne sur n”importe quoi. Deux modes de défaillance s”ensuivent.

Si l”horloge du client est lente (en retard sur le serveur), il croit le bail encore vivant après que le serveur le considère expiré. Inoffensif pour le comptage — le serveur récupère le siège à l”heure quoi que le client croie —, mais cela signifie qu”un client peut agir brièvement sur un bail que le serveur a déjà récupéré. Gardez la fenêtre d”application étroite et c”est un effet sous-TTL que le prochain battement corrige.

Si l”horloge du client est rapide (en avance sur le serveur), il croit le bail expiré trop tôt et se dégrade ou ré-acquiert trop tôt. Agaçant mais pas une faille de licence : l”utilisateur ne perd rien de ce qu”il a payé ; il génère juste un checkout de plus.

La tolérance de décalage (policy.Skew) existe pour absorber les quelques secondes normales de dérive afin qu”aucun des deux côtés ne papillonne. Ce qu”elle ne doit pas faire, c”est devenir une porte dérobée : ne laissez jamais l”horloge du client étendre un bail. L”expiration est signée par le serveur, le serveur récupère sur sa propre horloge, et l”horloge du client ne décide que de rendre un siège plus tôt. Cette asymétrie — le client peut libérer tôt, seul le serveur peut étendre — est ce qui empêche « reculez votre horloge » d”être un exploit d”accaparement de sièges. C”est la même discipline qui déjoue le recul d”horloge d”essai, appliquée à la concurrence.

Ce que vous livrez réellement

Les pièces sont petites, et l”essentiel de la correction tient dans deux règles énonçables en une phrase chacune. Un siège est vivant si et seulement si now <= ExpiresAt + skew, et le compteur comme le récupérateur obéissent à cet unique prédicat. Le client peut libérer un siège tôt mais ne peut jamais en étendre un : seule la signature du serveur le fait. Réussissez ces deux-là et le reste est de la plomberie : un enregistrement de bail signé, un checkout qui verrouille par licence et compte par expiration, un battement qui renouvelle sans toucher le compte, un gardien d”arrière-plan côté client qui distingue une requête perdue d”un bail expiré, et un récupérateur qui range sur une marge. La récompense est un compte de sièges concurrents qui se répare lui-même : un client qui achète dix sièges peut toujours en exécuter dix, un client planté lui coûte un siège pendant au plus un TTL, et personne n”a à ouvrir un ticket parce que la licence a mangé ses sièges en silence.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.