Mi Problema 🤔
Durante un proceso de refactorización para convertir una funcionalidad previamente secuencial en una concurrente, me encontré con un problema clásico: garantizar la unicidad de los registros cuando múltiples tareas intentan crear eventos al mismo tiempo. Aunque el objetivo era mejorar el rendimiento procesando miles de eventos en paralelo usando Swift, la transición expuso un desafío típico de concurrencia: evitar duplicados y mantener la integridad de los datos bajo acceso simultáneo.
El bug resultó ser una condición de carrera lógica del tipo check-then-act. Cuando múltiples tareas concurrentes intentan crear el mismo evento, todas pueden comprobar que no existe y proceder a crearlo al mismo tiempo. Solo una de las instancias resultantes se almacena, mientras que el resto se convierten en huérfanas, lo que provoca inconsistencias y referencias rotas en otras estructuras de datos.
Por ejemplo: dos tareas concurrentes comprueban si un evento existe. Ambas ven que no, ambas lo crean, pero solo una sobrevive en el diccionario; la otra referencia ahora está perdida.
Mi Solución 🧩
Llegué a la solución final a través de una progresión de intentos, cada uno de ellos revelando los límites del anterior.
Actor con array
Mi primer intento guardaba los eventos en un array protegido por un actor.
private actor EventsActor {
private var events: [SportEventModel] = []
func getOrCreate(
name: String,
cityId: UUID,
build: () async throws -> EventModel
) async throws -> EventModel {
if let event = events.first(
where: {
$0.normalizedName == name
&& $0.$city.id == cityId
}
) { return event }
let event = try await build()
events.append(event)
return event
}
}
Ventajas:
- Simplicidad.
- Seguridad frente a condiciones de carrera.
Desventaja:
- Búsqueda ineficiente en grandes volúmenes O(n).
Actor con diccionario
Para acelerar las búsquedas, cambié el array por un diccionario indexado por nombre y ciudad.
private actor EventsActor {
private var events: [String: EventModel] = [:]
func getOrCreate(
name: String,
cityId: UUID,
build: () async throws -> EventModel
) async throws -> EventModel {
let key = "\(name)\(cityId.uuidString)"
if let event = events[key] {
return event
}
let event = try await build()
events[key] = event
return event
}
}
Ventajas:
- Búsqueda e inserción rápida O(1).
- Ideal para grandes volúmenes de datos.
Bug:
- Condición de carrera lógica
Actor con una tarea por clave
Para evitar esto, serializo no solo el acceso sino también la creación por clave. Si ya hay una creación en curso para esa clave, las tareas concurrentes deben esperar el resultado de la primera, asegurando que todas compartan exactamente el mismo recurso.
private actor EventsActor {
private var events: [String: EventModel] = [:]
private var builds: [String: Task<EventModel, Error>] = [:]
func getOrCreate(
name: String,
cityId: UUID,
build: @Sendable @escaping () async throws -> EventModel
) async throws -> EventModel {
let key = "\(name)\(cityId.uuidString)"
if let event = events[key] {
return event
}
if let building = builds[key] {
return try await building.value
}
let buildTask = Task {
try await build()
}
builds[key] = buildTask
let event = try await buildTask.value
events[key] = event
builds.removeValue(forKey: key)
return event
}
}
Mi Resultado 🎯
- Un actor por sí solo no previene condiciones de carrera lógicas del tipo “check-then-act”.
- En escenarios concurrentes, serializar la construcción del recurso por clave es fundamental para mantener la integridad de los datos.
- Es esencial probar bajo carga y escenarios concurrentes, no solo en modo secuencial.
Keep coding, keep running 🏃♂️