Tema representativo de entrevista

Entrevista técnica de código: Implementar coalescencia de solicitudes por clave (singleflight)

CodingDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Implemente un helper asíncrono y seguro para la concurrencia donde los llamadores concurrentes con la misma clave compartan el resultado o error de una única tarea, mientras que diferentes claves se ejecutan de forma independiente; explique tiempos de espera (timeout), cancelación, limpieza y pruebas.

Prompt y contexto

Implemente un helper asíncrono seguro para la concurrencia, coalesce(key, task). Como máximo, un task puede ejecutarse para un key determinado a la vez. Los llamadores concurrentes con la misma clave deben esperar (await) y recibir exactamente el mismo valor o error; las diferentes claves deben ejecutarse de forma independiente.

La tarea puede lanzar un error sincrónicamente o ser rechazada asincrónicamente. Los llamadores pueden configurar su propio tiempo de espera (timeout). La entrada debe eliminarse tanto tras el éxito como tras el fallo, de modo que una llamada posterior pueda reintentar. Explique la semántica de cancelación, la propagación de errores y las pruebas.

Lo que el entrevistador está evaluando

El núcleo es convertir la desduplicación en un invariante de concurrencia demostrable: instalar la promesa compartida antes de comenzar el trabajo asíncrono, eliminar únicamente cuando el mapa todavía apunte a esa entrada y mantener las claves independientes. El entrevistador también quiere que distinga entre un llamador que abandona su espera y la cancelación del trabajo compartido, además de abordar el crecimiento desmedido de la memoria.

Preguntas de clarificación

  1. ¿Las claves deben ser no vacías o normalizadas? Rechazaría una clave vacía para que solicitudes no relacionadas no colapsen accidentalmente.
  2. ¿El timeout de un llamador debería cancelar el trabajo aguas arriba? Por defecto, solo detiene la espera de ese llamador y deja el trabajo compartido en ejecución para otros en espera.
  3. ¿Se deben cachear los errores? No. Elimine después de la resolución (settlement) para que la siguiente llamada reintente.
  4. ¿Se requiere coalescencia entre procesos? No; este prompt trata de memoria en un solo proceso. La coordinación entre procesos es un diseño aparte.

Respuesta de 30 segundos

Mantengo el trabajo en curso en un Map<key, Entry>. Al entrar, retorno la promesa existente si hay un acierto. Si no lo hay, creo la promesa, la coloco en el mapa antes de esperar (await) y luego ejecuto la tarea. En finally, elimino solo si el mapa aún contiene esa misma entrada. Los llamadores con la misma clave comparten una ejecución, las claves diferentes no se bloquean entre sí y las fallas liberan el estado para reintentar. El timeout de un llamador compite con su espera sin cancelar el trabajo compartido. Las pruebas cubren llamadas duplicadas, claves independientes, lanzamientos sincrónicos, rechazo y reintento, y condiciones de carrera en la limpieza.

Análisis detallado paso a paso

Defina un Entry que pueda contener la promesa compartida y, si es necesario, un controlador interno. El orden es la parte importante:

ts
const inFlight = new Map<string, Promise<unknown>>();

function coalesce<T>(key: string, task: () => Promise<T>): Promise<T> {
  if (!key) return Promise.reject(new Error("key must not be empty"));
  const existing = inFlight.get(key);
  if (existing) return existing as Promise<T>;

  let shared: Promise<T>;
  try {
    shared = Promise.resolve().then(task);
  } catch (error) {
    shared = Promise.reject(error);
  }
  inFlight.set(key, shared);
  shared.finally(() => {
    if (inFlight.get(key) === shared) inFlight.delete(key);
  }).catch(() => undefined);
  return shared;
}

Una entrada de objeto puede registrar adicionalmente la hora de inicio, el conteo de llamadores en espera y un AbortController. Promise.resolve().then(task) hace que los lanzamientos sincrónicos y los rechazos asincrónicos sigan una sola ruta. La inserción en el mapa debe ocurrir antes del primer await; de lo contrario, dos turnos del event loop pueden observar un fallo de búsqueda simultáneamente. La verificación de identidad evita que el finally de una tarea antigua elimine una entrada más nueva.

El timeout del llamador es una política externa:

ts
function waitWithTimeout<T>(shared: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    shared,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error("wait timeout")), ms),
    ),
  ]);
}

La tarea compartida aún se completa para los demás en espera. Si el producto requiere la cancelación cuando todos se van, agregue conteo de referencias y defina esa condición de carrera explícitamente en el contrato y las pruebas.

Las operaciones esperadas del mapa son O(1). Con K claves distintas en curso, el estado es O(K); entregar un resultado cuesta proporcionalmente al número de llamadores en espera. El código de producción debe limitar la cardinalidad de claves y exponer métricas de duración, timeout, conteo de llamadores y errores para que el mapa no se convierta en una caché ilimitada.

Respuesta de muestra de alta calidad

Primero establecería los límites: un solo proceso, solo desduplicación en curso, sin caché de resultados. El Map almacena cada entrada. En caso de no encontrar la clave, creo y registro la promesa inmediatamente, luego invoco la tarea del usuario. Cada llamador recibe la misma promesa, por lo que los valores y errores son idénticos. La limpieza compara la identidad del objeto, evitando que una finalización antigua elimine una generación más nueva.

La cancelación significa "cancelar la espera, no el trabajo compartido": un llamador con timeout no envía un AbortError a otros llamadores ni interrumpe la única operación aguas arriba. Si se requiere una cancelación real, usaría un controlador compartido más conteo de referencias de llamadores y cancelaría solo cuando el conteo llegue a cero.

Para las pruebas, uso una barrera para liberar llamadores simultáneos y asegurar una única invocación de tarea y un resultado compartido. También pruebo claves independientes en paralelo, lanzamiento sincrónico, rechazo asincrónico, reintento tras fallo, una nueva tarea tras el éxito, un finally antiguo compitiendo con una nueva entrada y un llamador con timeout mientras otro aún tiene éxito. Terminaría con límites de capacidad y métricas para claves de alta cardinalidad.

Errores comunes

  • Esperar (await) a task() antes de insertarlo en el mapa, lo que permite una ejecución duplicada.
  • Eliminar incondicionalmente por clave en finally, permitiendo que una tarea antigua elimine una entrada más nueva.
  • Pasar el AbortSignal de un llamador directamente al trabajo compartido y cancelar a todos los que esperan.
  • Mantener una promesa rechazada para siempre en lugar de eliminarla para permitir reintentos.
  • Usar un bloqueo global y serializar claves no relacionadas.
  • Probar solo llamadas secuenciales en lugar de llegadas simultáneas, lanzamientos sincrónicos y condiciones de carrera en la limpieza.

Preguntas de seguimiento y respuestas

¿Cómo daría soporte a una cancelación real?

¿Cuándo se necesita coalescencia entre procesos?

¿Cómo previene fugas por claves de alta cardinalidad?

La cancelación real necesita un controlador compartido, conteo de referencias y una política explícita de cero llamadores. Los casos entre procesos requieren Redis, una puerta de enlace (gateway) u otro coordinador junto con leases, manejo de fallas del líder y tolerancia a ejecuciones duplicadas. Las claves de alta cardinalidad necesitan límites de capacidad, políticas de TTL o desalojo, comportamiento de rechazo y métricas; estos controles deben preservar la regla central de que solo se almacena el trabajo en curso.

Fuentes públicas

Preguntas relacionadas

Herramienta de entrevista relacionada

Usa Captura para un ejercicio de código

Captura el problema y luego aborda en orden las restricciones, la solución, el código, los casos extremos y la complejidad.

Ver la herramienta