SF

PostgreSQL · SignalR · Testcontainers

Concurrence optimiste : le conflit qu'on ne voit pas

Deux utilisateurs, un enregistrement, aucun écrasement silencieux. xmin, 409 et présence temps réel.

409
plutôt qu’un écrasement
1
colonne ajoutée : aucune
2
écritures concurrentes testées en CI

Le problème

Sur une plateforme de gestion des temps, plusieurs managers éditent les mêmes fiches simultanément — en fin de mois, sur les mêmes équipes. Le comportement par défaut d'Entity Framework est le « dernier qui écrit gagne » : la seconde sauvegarde écrase la première sans avertissement, sans trace, sans erreur. Personne ne s'en aperçoit avant qu'un client ne signale des heures disparues, et il est alors impossible de reconstituer ce qui a été perdu.

Utilisateur Axmin 1042
Utilisateur Bxmin 1042

db → "Refonte du portail client" · xmin 1042

L'approche

PostgreSQL maintient déjà un compteur de version système sur chaque ligne, xmin, incrémenté à chaque écriture. Plutôt que d'ajouter une colonne de version applicative à maintenir, je l'ai déclaré comme jeton de concurrence EF Core sur les agrégats concernés. Le client lit le xmin en même temps que la donnée et le renvoie à la sauvegarde ; si la valeur a bougé entre-temps, EF Core lève une DbUpdateConcurrencyException et la couche API la traduit en HTTP 409, sans jamais écrire. En complément, une editing presence temps réel en SignalR affiche un bandeau dès qu'un autre utilisateur ouvre le même champ — la collision est signalée avant de se produire, pas seulement rejetée après coup.

Extrait de codeTimesheetConfiguration.cs
// PostgreSQL already versions every row through the system column xmin.
// Using it as the concurrency token means no extra column to add, migrate
// or keep in sync — the database does the bookkeeping we would otherwise
// have to write and test ourselves.
public void Configure(EntityTypeBuilder<Timesheet> builder)
{
    builder.Property<uint>("xmin")
           .HasColumnType("xid")
           .ValueGeneratedOnAddOrUpdate()
           .IsConcurrencyToken();
}

// The API layer turns the EF exception into a contract the client can act on:
// the caller learns the row moved, and gets both values to show a real diff.
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateTimesheetRequest request)
{
    try
    {
        await _timesheets.UpdateAsync(id, request, request.RowVersion);
        return Ok(ApiResponse.Success());
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var current = ex.Entries.Single().GetDatabaseValues();

        return Conflict(ApiResponse.Conflict(
            expected: request.RowVersion,
            actual: current?["xmin"]));
    }
}

Le résultat

Les écrasements silencieux ont disparu du modèle : ils sont désormais structurellement impossibles sur les agrégats couverts. Le comportement est validé en intégration continue par des tests d'intégration PostgreSQL sous Docker et Testcontainers, qui rejouent deux écritures concurrentes réelles et vérifient à la fois le 409 et l'isolation multi-tenant — pas des mocks, une vraie base. La démonstration en haut de ce site reproduit fidèlement ce mécanisme.

ASP.NET Core Identity · JWT · SécuritéLa session qu'on croyait ferméeConformité · CQRS · MapperlySortir de MediatR et AutoMapper, en production