Es posible que te hayas encontrado en el escenario en el que varios usuarios intentan modificar un mismo elemento al mismo tiempo, lo cual va a generar que una de las dos actualizaciones se pierda.
En este post vamos a ver cómo podemos abordar este escenario para ver qué opciones tenemos.
Índice
0 - Preparar el sistema
Antes de comenzar, quiero decir una pequeña nota, durante todo el curso he estado utilizando MySQL pero para este post en concreto voy a utilizar PostgresQL; El motivo es que vamos a ver conflictos de concurrencia, y por algún motivo no lo podía configurar correctamente en MySQL.
El cambio que he realizado ha sido muy simple, he modificado el dockerfile
, para que en vez de descargar MySQL, descargue Postgres. Tanto el usuario, como la contraseña o la base de datos tienen el mismo nombre.
### code 0
FROM postgres:latest
ENV POSTGRES_USER=cursoEFuser
ENV POSTGRES_PASSWORD cursoEFpass
ENV POSTGRES_DB cursoEF
- Nota: para simplificar, he dejado las rutas de los archivos en
tools/myslq
.
En el código he añadido la connectionstring, y como vimos en el post de conectarse a múltiples bases de datos unicamente he cambiado la instrucción en el fichero startup y la connectionstring en appsettings.
"PostgreSQLConnection": "Server=127.0.0.1;Port=5432;Userid=cursoEFuser;Password=cursoEFpass;Database=cursoEF",
builder.Services.AddPostgreSql(builder.Configuration);
Y finalmente, he borrado las migraciones y corrido otra vez, esto se hace porque algunos tipos de datos son diferentes entre MySql y Postgres.
1 - ¿Qué es un conflicto de concurrencia?
Lo primero que tenemos que entender es que es un conflicto de concurrencia o en inglés (concurrency conflict / concurrency handling). Básicamente sucede cuando dos operaciones intentan modificar los mismos datos y esto es muy común dentro de las bases de datos.
Por ejemplo, si tenemos dos usuarios con permisos para cambiar el valor "email" y uno lo cambia, el segundo hará dos cosas, o fallar, o sobreescribir el cambio realizado por el primero; pero lo sobreescribirá, sin saber que lo está sobreescribiendo.
1.1 - Técnicas de manejo de conflictos de concurrencia
-
Concurrencia Pesimista: Supone que los conflictos son probablemente la regla, no la excepción. Bloquea el registro tan pronto como un usuario comienza a editarlo, evitando así que otros usuarios lo modifiquen.
-
Concurrencia Optimista: Supone que los conflictos son raros. Permite que los usuarios editen registros sin bloquearlos y comprueba si hubo conflictos al guardar los cambios.
2 - Solución al problema de concurrencia con Entity Framework core
Entity framework Core nos trae en la propia librería un mecanismo para implementar de una forma muy sencilla la concurrencia optimista, que asume que los conflictos suceden de forma puntual (cosa que es cierto, en la gran mayoría de casos) .
Este tipo de manejo de la concurrencia lo que hace es, en vez de bloquear el registro comprueba si la información ha cambiado desde que se leyeron los datos originales que estás modificando hasta que guardas, y si es diferente, falla.
Esta comprobación sucede durante el SaveChanges
del DbContext
.
2.1 - Implementar validación de Concurrencia en entity framework core
Ejemplo práctico, tenemos el siguiente endpoint, donde actualizamos el email:
[HttpPut("concurrency/update-email/{id}")]
public async Task<bool> UpdateEmail(int id, string newEmail)
{
User? user = await _unitOfWork.UserRepository.GetById(id);
if (user != null)
{
//Sleep 10 seconds to be able to test the concurrency issue
Thread.Sleep(10000);
user.Email = newEmail;
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.Save();
}
return true;
}
Cuando ejecutemos dos llamadas a la vez, ambos resultados van a ser correctos, ya que se guardan ambas versiones.
Entonces, para evitar esta situación lo primero debemos especificar en la entidad que campos son los que queremos comprobar que no han cambiado, y ello lo hacemos con el atributo ConcurrencyCheck
dentro de la entidad que queremos comprobar, en nuestro caso es únicamente con el email.
public class User
{
public int Id { get; set; }
public string UserName { get; set; }
[ConcurrencyCheck]
[MaxLength(50)]
public string Email { get; set; }
public ICollection<Wokringexperience> Wokringexperiences { get; set; }
}
Con este cambio lo que hacemos es indicar a la base de datos que añada en el where de la actualización, el email antiguo.
Y si no tiene registros que actualizar salta una excepción.
- Nota: Esto es lo que no funcionaba como se espera en MySQL
Anteriormente o alternativamente, podíamos utilizar la columna RowVersion
, que seguramente muchos la hayais visto, especialmen te en código más antiguo, su efecto era el mismo, y esta columna era bien o el tipo byte[]
, o de tipo timestamp
, la cosa es que había que calcularla en cada cambio.
De hecho, nuestro cambio se podría aplicar también a esa columna adicional, ya que ahora mismo con el atributo ConcurrencyCheck
solo comprobamos que el email no cambia (se puede poner el concurrency check a cada columna, como siempre depende de tu caso de uso).
Si decidimos ir por el ConcurrencyCheck
en la columna RowVersion
, debemos acordarnos de actualizar dicha columna en cada actualización, igual que se hacía en el pasado con byte[]
o timestamp
.
De hecho, voy a realizar dicho cambio, en nuestro caso particular, la propiedad irá en la clase CursoEFBaseEntity<TId>
ya que tenemos centralizadas ciertas acciones.
public abstract class CursoEFBaseEntity<TId>
{
public TId Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedTimeUtc { get; set; }
[ConcurrencyCheck]
public DateTime LastUpdateUtc { get; set; }
}
En mi caso, la pongo del tipo DateTime, pero como digo, puede ser cualquiera, long, Guid, etc.
Y modificamos el update para que actualice la columna:
public abstract class GenericRepository<T, TId> : IGenericRepository<T, TId>
where T : CursoEFBaseEntity<TId>
where TId : IEquatable<TId>
{
private readonly CursoEfContext _context;
protected DbSet<T> Entities => _context.Set<T>();
protected GenericRepository(CursoEfContext context)
{
_context = context;
}
public void Update(T entity)
{
entity.LastUpdateUtc = DateTime.UtcNow;
_context.Set<T>().Update(entity);
}
}
Ahora podemos quitar el ConcurrencyCheck
del email y corremos las migraciones otra vez.
Finalmente si probamos vemos como la segunda vez que hacemos la llamada nos salta una excepción.
Esto es lo que se llama una ConcurrencyException
, y ahora depende de ti como quieras tratarla.
2.2 - Lidiar con excepciones de concurrencia en Entity Framework Core
Hay varias formas de lidiar con los conflictos de concurrencia, que viene siendo o devolver un error, o re-intentar unas cuantas veces y luego devolver un error.
Para realizar esta acción debemos manejar la excepción que en este caso es DbUpdateConcurrencyException
y debemos hacer el try catch en el SaveChangesAsync
del DbContext que en nuestro caso está dentro de UnitOfWork.
public class UnitOfWork : IUnitOfWork
{
public IUserRepository UserRepository { get; }
public IWorkingExperienceRepository WorkingExperienceRepository { get; }
private readonly CursoEfContext _context;
public UnitOfWork(CursoEfContext context, IUserRepository userRepository,
IWorkingExperienceRepository workingExperienceRepository)
{
_context = context;
UserRepository = userRepository;
WorkingExperienceRepository = workingExperienceRepository;
}
public async Task<int> Save()
{
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException concurrencyException)
{
Console.WriteLine("error de concurrencia");
//Manejar la excepción del conflicto
}
return 0;
}
public void Dispose()
{
_context.Dispose();
}
}
En este caso, simplemente ignoramos el error y devolvemos 0; es tarea del consumidor de nuestra interfaz realizar las acciones necesarias en el código, en algunos casos será volver a intentar y en otros devolver un error. Lo importante es que no se sobreescriben datos de manera inintencionada.