Tema representativo de entrevista

¿Cómo implementar Promise.all con el orden y la semántica de fallos correctos?

FrontendIntermedio
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Sin invocar Promise.all, implementa promiseAll(iterable) de modo que reproduzca el comportamiento fundamental del método nativo.

Problema y contexto aplicable

Implementa promiseAll(iterable) sin llamar al Promise.all nativo dentro de tu solución. La entrada es un iterable sincrónico finito que contiene promesas, thenables o valores directos. La salida es una nueva Promise.

Cuando todas las entradas se cumplen, el resultado debe ser un arreglo en el orden de iteración, independientemente del orden de finalización. Cuando cualquier entrada se rechaza, la Promise externa debe rechazarse con el motivo del primer rechazo ocurrido. Una entrada vacía debe producir []. Si la lectura del propio iterador lanza un error, la Promise externa también debe rechazarse.

Este ejercicio es adecuado para una ronda de live coding para un puesto de frontend o JavaScript. La solución puede usar el Promise nativo para asimilar thenables y programar reacciones asincrónicas. No necesita reproducir cada detalle de la especificación relacionado con constructores de subclases, slots internos y el cierre de iteradores. Establece este límite antes de programar: el "comportamiento fundamental" y una "implementación totalmente compatible con la especificación" son tareas distintas.

Qué evalúa el entrevistador

Una respuesta sólida enumera la semántica antes de escribir el bucle. Cinco señales útiles son: aceptar un iterable en lugar de solo un arreglo, normalizar valores directos y thenables mediante Promise.resolve, reservar un índice para preservar el orden, manejar la entrada vacía por separado y redirigir tanto los errores de iteración como los rechazos de promesas al rechazo externo.

Una respuesta común llama a map sobre un arreglo y hace push de cada valor completado. Puede superar un ejemplo en el que todo se cumple en orden, pero fallará con un Set, un generador, una entrada vacía o una finalización en desorden. Una respuesta más sólida parte de invariantes: results[i] siempre pertenece al valor en el índice de iteración i; pending es el número de entradas que no se han cumplido; el cumplimiento se permite solo cuando pending llega a cero.

El entrevistador también notará si se confunde "fallar rápido" (fail-fast) con "cancelar el resto". Una vez que la Promise externa se rechaza, los intentos posteriores no pueden cambiar su estado, pero el trabajo que ya ha comenzado continúa. La cancelación requiere un protocolo adicional soportado por las operaciones de entrada; una Promise agregada no puede inventar esa capacidad.

Preguntas para aclarar antes de responder

  • ¿La entrada es siempre un arreglo o cualquier iterable sincrónico? Soportar solo arreglos permite un bucle por índice. Soportar el contrato de iterable indicado requiere for...of y manejar los errores lanzados al solicitar el siguiente valor.
  • ¿Los valores directos y thenables son entradas válidas? De ser así, cada elemento debe pasar por Promise.resolve. Invocar item.then directamente falla con números, cadenas y objetos comunes.
  • ¿Es este un polyfill a nivel de especificación o una implementación para entrevista del comportamiento fundamental? Una versión a nivel de especificación también aborda el constructor this, subclases de Promise, protecciones internas contra llamadas repetidas y reglas exactas de cierre de iteradores. Esta solución devuelve una Promise nativa y no pretende una conformidad total.
  • ¿Deben cancelarse las operaciones restantes después de un fallo? De ser así, el enunciado debe definir una interfaz AbortSignal o de cancelación de tareas. El contrato actual solo rechaza la Promise externa de forma temprana; las demás operaciones continúan ejecutándose.
  • ¿La entrada es finita? Promise.all consume su entrada de forma sincrónica. El recorrido de un iterador infinito nunca termina. Esta solución asume una entrada finita para que el espacio pueda describirse en términos de n.

Estas respuestas cambian el bucle, el manejo de errores y el contrato de la API, por lo que vale la pena confirmarlas antes de la implementación.

Estructura de respuesta de 30 segundos

"Devolveré una nueva Promise y recorreré sincrónicamente el iterable finito. Para cada elemento, reservo su índice de iteración, incremento un contador de pendientes y uso Promise.resolve para normalizar un valor directo, thenable o Promise. Un manejador de cumplimiento escribe en el índice reservado y resuelve el arreglo resultante cuando el contador llega a cero. El manejador de rechazo rechaza la Promise externa de inmediato. Envolveré la iteración en try...catch para que un error del iterador también cause un rechazo. La entrada vacía no tiene manejador de cumplimiento, por lo que resolveré [] explícitamente tras el recorrido. Esto preserva el orden de entrada y falla rápido, pero no cancela las operaciones que ya han comenzado."

Análisis detallado paso a paso

Comencemos con un enfoque tentador que no es equivalente: hacer await de cada elemento en secuencia y recolectar los resultados. Esto preserva el orden, pero serializa esperas que de otro modo podrían solaparse. Hasta que el elemento anterior no se asienta, la siguiente reacción ni siquiera se registra. La operación solicitada agrega entradas que ya se han obtenido; no es una cola de tareas secuenciales.

La implementación recomendada necesita un arreglo de resultados, dos contadores y un recorrido:

javascript
function promiseAll(iterable) {
  return new Promise((resolve, reject) => {
    const results = [];
    let pending = 0;
    let index = 0;

    try {
      for (const item of iterable) {
        const currentIndex = index;
        index += 1;
        pending += 1;

        Promise.resolve(item).then(
          (value) => {
            results[currentIndex] = value;
            pending -= 1;

            if (pending === 0) {
              resolve(results);
            }
          },
          reject,
        );
      }
    } catch (error) {
      reject(error);
      return;
    }

    if (index === 0) {
      resolve([]);
    }
  });
}

currentIndex queda fijo durante esa iteración. Supongamos que tres entradas se asientan después de 30, 10 y 20 milisegundos. Sus manejadores de cumplimiento se ejecutan en el orden 1, 2, 0, pero siguen escribiendo en las posiciones 1, 2, 0, de modo que el arreglo final se devuelve en el orden 0, 1, 2. Reemplazar la asignación indexada por results.push(value) devolvería incorrectamente el orden de finalización.

Promise.resolve(item) cubre dos casos límite a la vez. Un valor directo se convierte en una Promise ya cumplida. A un thenable se le asimila su método then; incluso si un thenable defectuoso invoca sus callbacks más de una vez, el estado unidireccional de la Promise nativa evita que liquidaciones repetidas lleguen a este manejador. Una prueba con item instanceof Promise omitiría tanto Promises de otro reino (realm) como thenables válidos.

La entrada vacía necesita una bifurcación explícita. El contador comienza en cero y no existe ningún manejador de cumplimiento que llame a resolve. Verificar index === 0 después del recorrido cumple la Promise devuelta con un arreglo vacío. Un .then registrado por el invocador se sigue ejecutando de forma asincrónica bajo las reglas normales de Promise.

El try...catch maneja fallos de iteración sincrónicos. Por ejemplo, un generador puede lanzar una excepción en su segunda llamada a next() después de que ya se haya adjuntado un manejador al primer valor. El bloque catch rechaza la Promise externa; si el valor anterior se cumple más adelante, su manejador no puede cambiar el estado ya rechazado. Los rechazos de elementos individuales se canalizan hacia el mismo reject. Si varias entradas se rechazan, el manejador de rechazo que se ejecute primero determina el motivo externo.

El recorrido y la resolución realizan un trabajo total de O(n). El arreglo de resultados y las reacciones de cumplimiento por elemento requieren un espacio de O(n). Procesar un cumplimiento añade un trabajo de O(1) para una escritura indexada y un decremento. Si el producto necesita cada cumplimiento y rechazo, utiliza un contrato all-settled. Si necesita un límite de concurrencia, acepta fábricas de tareas (task factories) y añade un planificador; pasar a esta función una colección de Promises que ya han comenzado no puede limitar la concurrencia de forma retroactiva.

La validación debe ir más allá del camino feliz (happy path). Cubre un iterable vacío; un Set; una mezcla de valores directos, Promises y thenables; finalización en orden inverso al de entrada; el rechazo más temprano; y un iterador que lanza una excepción a mitad de camino. Cada caso prueba un invariante específico, lo cual resulta más convincente que comparar un solo arreglo de muestra.

Respuesta de ejemplo de alta calidad

"Delimitaré esto a un iterable sincrónico finito y al resultado de una Promise nativa, sin pretender una conformidad total con subclases de Promise. Tres semánticas deben cumplirse: los resultados siguen el orden de iteración, la Promise externa se rechaza tan pronto como se rechaza una entrada, y una entrada vacía se cumple con un arreglo vacío.

Durante el recorrido, asigno a cada elemento un índice estable e incremento pending. Cada elemento pasa por Promise.resolve, de modo que números, Promises existentes y thenables comparten un único camino. Al cumplirse, escribo en la posición estable y decremento el contador; el último cumplimiento resuelve el arreglo completo. El manejador de rechazo es el reject externo. Coloco for...of dentro de try...catch porque obtener el siguiente valor del iterable puede lanzar una excepción de forma sincrónica. Si el recorrido no encuentra elementos, resuelvo con [] directamente.

El recorrido y la resolución toman O(n) de trabajo y O(n) de espacio. El comportamiento fail-fast cambia solo el resultado agregado; no detiene las otras operaciones asincrónicas. Si se requiere cancelación, agregaría un AbortSignal al contrato de tareas de entrada. Si se deben recolectar todos los errores, usaría la semántica de all-settled en lugar de cambiar la regla de rechazo de esta función."

Errores comunes

  • Recolectar valores cumplidos con push el orden del arreglo sigue la velocidad de finalización, por lo que un primer elemento lento puede aparecer al final → captura currentIndex durante el recorrido y asigna results[currentIndex].
  • Llamar solo a item.then(...) los valores directos no tienen then, y los thenables no nativos no se asimilan de forma segura → normaliza cada elemento con Promise.resolve(item).
  • Inicializar el contador de pendientes a partir de una longitud de entrada afirmando soportar iterables → los Sets y generadores no tienen una propiedad length confiable, reduciendo silenciosamente el contrato → incrementa pending a medida que se recorren los valores.
  • Olvidar la entrada vacía → ningún manejador puede desencadenar el cumplimiento, dejando la Promise devuelta pendiente para siempre → resuelve un arreglo vacío cuando index === 0 tras el recorrido.
  • Usar forEach(async ...) y luego hacer await sobre él → forEach no espera callbacks asincrónicos, por lo que el flujo de control y la propagación de errores son incorrectos → registra manejadores .then directamente y agrégalos con el contador.
  • Manejar únicamente rechazos de entrada → una excepción sincrónica lanzada desde el next() de un iterable escapa a la lógica → envuelve todo el recorrido en try...catch y rechaza la Promise externa.
  • Afirmar que el comportamiento fail-fast cancela peticiones → un estado de Promise irreversible no detiene la operación subyacente → indica que la cancelación requiere AbortSignal u otro protocolo de cancelación a nivel de tarea.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Cómo puedes demostrar que el orden de los resultados es correcto?

Utiliza este invariante: el valor en el índice de iteración i posee únicamente el índice i, y su manejador de cumplimiento escribe únicamente en results[i]. El orden de los manejadores cambia cuándo ocurre una escritura, nunca dónde ocurre. Por lo tanto, después de que se cumplen las n entradas, las posiciones 0 hasta n - 1 contienen los valores de las entradas correspondientes. Prueba esta afirmación con tres Promises cuyos retrasos sean inversos al orden de entrada en lugar de confiar en retrasos iguales.

Pregunta de seguimiento 2: ¿Cómo cancelarías las solicitudes de red restantes tras un rechazo?

La función actual no puede hacerlo, porque una Promise no expone el punto de entrada de cancelación de la operación subyacente. Cambia la entrada a fábricas de tareas que reciban un AbortSignal y crea un AbortController compartido. Cuando una tarea falle, llama a controller.abort() antes de rechazar la Promise externa. Algunas tareas ya pueden haber terminado, ignorar la señal o fallar durante la cancelación, por lo que la cancelación de solicitudes es un contrato extendido y no una parte oculta de la semántica estándar de Promise.all.

Pregunta de seguimiento 3: ¿Qué pasa si como máximo pueden ejecutarse tres tareas a la vez?

Cambia la entrada de Promises ya iniciadas a funciones que no hayan comenzado. Realiza un seguimiento del índice de la siguiente tarea, del número de tareas en ejecución actual y del arreglo de resultados. Inicia otra tarea cada vez que una se liquide, manteniendo el conteo de ejecución en 3 o menos. Si la API aún acepta un arreglo de Promises, sus operaciones generalmente comienzan mientras se construye ese arreglo, por lo que el planificador ya llega demasiado tarde. La cuestión clave es el momento de inicio, no renombrar el contador de finalización.

Pregunta de seguimiento 4: ¿Qué diferencias quedan respecto a un Promise.all a nivel de especificación?

Esta implementación siempre utiliza el Promise nativo. No obtiene un constructor a partir de this, ni reproduce el comportamiento de subclases de Promise, slots internos, los pasos de cierre de iteradores de la especificación ni todos los detalles de terminación abrupta. En una entrevista, descríbela como una implementación del comportamiento fundamental. Un polyfill publicable requiere un mapeo paso a paso con el algoritmo de ECMAScript junto con pruebas de compatibilidad; los ejemplos comunes por sí solos no garantizan la conformidad.

Fuentes públicas

Preguntas relacionadas