Ir al contenido

Generación por lotes en Connect con seguimiento de progreso

Procesa una lista de documentos hasta completarla desde un único proceso cliente en NextPDF Connect, la distribución de servicio HTTP independiente del motor. Esta recipe envía cada solicitud de renderizado al endpoint de trabajos asíncronos POST /api/v1/jobs, consulta cada trabajo con GET /api/v1/jobs/{id} hasta que alcanza un estado terminal, lee los campos status y progress que informa el servidor para cada trabajo, y descarga cada PDF completado desde GET /api/v1/jobs/{id}/result.

El ciclo de vida del trabajo es fijo y pequeño. Un trabajo está pending, luego running, y luego exactamente en un estado terminal: completed, failed o cancelled. La respuesta de estado incluye un entero progress de 0 a 100 cuando el servidor lo rastrea, y un encabezado Retry-After en cada sondeo no terminal que indica cuánto esperar antes de la siguiente solicitud. Identificar cada envío con un Idempotency-Key hace que un envío reintentado devuelva el mismo trabajo en lugar de iniciar un segundo renderizado.

Esta recipe sigue el camino directo a nivel de protocolo, sin ocultar el transporte. Usa la superficie REST directamente y no asume ningún kit de desarrollo de software (SDK) específico de un lenguaje, así que el mismo flujo se puede trasladar a cualquier cliente HTTP.

En el servidor se usa la distribución estándar de Connect:

Ventana de terminal
composer require nextpdf/server

El cliente PHP del ejemplo de producción que aparece más abajo usa un cliente HTTP y fábricas de mensajes que cumplen con PSR-18 y PSR-17. Instala las implementaciones que el proyecto ya utilice como estándar, por ejemplo:

Ventana de terminal
composer require psr/http-client psr/http-factory

La superficie de trabajos asíncronos separa el envío de la recuperación. No se mantiene una conexión HTTP larga abierta por documento. En su lugar, se envía un trabajo, se recibe un identificador y se consulta un endpoint de estado ligero hasta que el trabajo termina. Esa forma hace que un lote sea manejable: el cliente rastrea N trabajos independientes a la vez sin N conexiones bloqueadas.

Tres endpoints llevan el flujo:

  • POST /api/v1/jobs acepta el mismo cuerpo de solicitud de renderizado que el endpoint síncrono /api/v1/render: un page_size, una orientation y un arreglo ordenado operations. Devuelve 201 Created para un trabajo nuevo, o 200 OK cuando un Idempotency-Key coincide con un trabajo ya enviado.
  • GET /api/v1/jobs/{id} devuelve el registro actual del trabajo. Para un trabajo no terminal también establece un encabezado Retry-After (el servidor usa un intervalo de 2 segundos) y un campo poll_url. Respeta el encabezado en lugar de sondear en un bucle cerrado.
  • GET /api/v1/jobs/{id}/result transmite el PDF terminado como application/pdf. Devuelve 409 Conflict si el trabajo no ha alcanzado completed, así que debe llamarse solo una vez que el sondeo de estado confirme el estado terminal.

Cada respuesta correcta comparte un mismo envoltorio: un objeto data con los campos del trabajo, y un objeto meta con el request_id, timestamp, duration_ms y api_version. Los campos del trabajo que se leen viven bajo data: data.status, data.progress, data.job_id y, en un trabajo completado, data.result_url.

Una advertencia sobre la versión actual. El servidor procesa un trabajo enviado en línea antes de responder al POST, así que en la práctica la respuesta del envío ya lleva un status terminal, y el resultado puede estar listo en el primer sondeo. El contrato de sondeo y progreso documentado aquí es la forma estable de la API. El servidor lo mantiene sin cambios a medida que el backend de procesamiento pasa a un grupo de workers en cola, así que un cliente escrito para sondear es correcto hoy y seguirá siéndolo después de ese cambio. Debe implementarse el bucle de sondeo. No debe asumirse que la primera respuesta es no terminal, ni tampoco que es terminal.

La superficie REST de trabajos asíncronos de Connect, tal como la definen el documento OpenAPI del servidor y el enrutamiento de JobHandler:

  • POST /api/v1/jobs: envía un trabajo de renderizado. Encabezado de solicitud Idempotency-Key opcional. El cuerpo es una solicitud de renderizado (operations es obligatorio y debe contener al menos una operación). Respuestas: 201 nuevo, 200 repetición idempotente, 422 cuerpo inválido, 409 conflicto de idempotencia, 429 por límite de tasa.
  • GET /api/v1/jobs/{id}: consulta el estado. Respuesta 200 con el registro del trabajo; encabezado Retry-After presente mientras el trabajo no es terminal; 404 si el trabajo no existe o pertenece a otro cliente.
  • GET /api/v1/jobs/{id}/result: descarga el PDF. 200 application/pdf cuando está completed; 409 cuando aún no está completado; 404 si el trabajo es desconocido.
  • DELETE /api/v1/jobs/{id}: cancela un trabajo pending o running, o elimina uno completed (204).

El registro del trabajo bajo data lleva estos campos, exactamente como el servidor los serializa.

  • job_id: el identificador (un prefijo job_ y 24 caracteres hexadecimales).
  • status: uno de pending, running, completed, failed, cancelled. Los dos primeros son no terminales; los tres últimos son terminales.
  • created_at y, una vez establecidos, started_at y completed_at: marcas de tiempo ISO-8601.
  • progress: un entero de 0 a 100, presente solo cuando el servidor lo rastrea para el trabajo; ausente (tratarlo como desconocido) en caso contrario.
  • error: una cadena de mensaje, presente solo en un trabajo failed.
  • result_url: presente solo en un trabajo completed; la ruta a la descarga del resultado.
  • poll_url: presente solo mientras el trabajo es no terminal.

La autenticación es un token de portador en el encabezado Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Este ejemplo lleva un trabajo de principio a fin a nivel de protocolo para mostrar las tres llamadas y los campos que devuelven. Envía, sondea una vez y descarga. El ejemplo de producción de más abajo añade el bucle por lotes, la espera de Retry-After y el manejo completo de errores.

Ventana de terminal
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

Este es un cliente autónomo. Envía un lote de solicitudes de renderizado, limita cuántos trabajos hay en curso a la vez, sondea cada trabajo con la cadencia que el servidor solicita mediante Retry-After, informa el progress reportado por el servidor, descarga cada PDF completado y registra los fallos. Usa un cliente HTTP PSR-18 y fábricas PSR-17 (el contrato de transporte que las recipes de Connect adoptan como estándar), y captura la excepción más específica que cada llamada puede lanzar: Psr\Http\Client\ClientExceptionInterface para un fallo de transporte, y una BatchJobException tipada para una respuesta del servidor con la que el lote no puede continuar. Ningún bloque catch está vacío. Cada uno registra y vuelve a lanzar, o bien registra un resultado definido.

Sustituye la lista $documents en línea por las entradas propias. Inyecta el cliente HTTP y las fábricas concretas del proyecto donde el constructor espera las interfaces PSR.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

STDOUT esperado, una línea por documento; las rutas dependen de tu directorio de salida:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Lee los campos del trabajo bajo data, no en el nivel superior. Cada respuesta correcta viene dentro de un envoltorio { "data": ..., "meta": ... }. data.status y data.progress son los campos sobre los que se actúa; meta lleva request_id para la correlación de soporte.
  • progress puede estar ausente. El servidor incluye progress solo cuando lo rastrea para ese trabajo. Trata un campo ausente como «desconocido», no como cero, y guía el bucle por status, que siempre está presente.
  • El envío puede ser ya terminal. En la versión actual el servidor renderiza en línea antes de responder al POST, así que la respuesta del envío puede llevar status: completed y el resultado puede estar listo en el primer sondeo. El bucle de sondeo debe aceptar un estado terminal en el intento cero en lugar de insistir primero en un pending.
  • Respeta Retry-After. Las respuestas de estado no terminales establecen Retry-After (un intervalo de 2 segundos). Sondear más rápido desperdicia solicitudes e invita a un 429. Acota el valor a un rango sensato en lugar de confiar en él ciegamente.
  • /result antes de completarse es un 409. Llama al endpoint de resultado solo después de que el sondeo de estado muestre completed. Un 409 Conflict significa que el trabajo no está terminado; no es un error de transporte.
  • Idempotency-Key evita el trabajo duplicado. Un envío reintentado con la misma clave devuelve el trabajo original (200 en lugar de 201). Usa una clave estable por documento para que un reintento de red nunca inicie un segundo renderizado. Una clave reutilizada con un cuerpo diferente es un conflicto 409.
  • Los trabajos están limitados al propietario. Un trabajo enviado con una clave de API es invisible para otra; un GET entre propietarios distintos devuelve 404, no 403. Sondea con la misma credencial con la que se hizo el envío.
  • Un trabajo failed lleva un mensaje error. Lee data.error en un estado terminal failed y regístralo. No reintentes a ciegas.

El costo de un lote es la suma de los renderizados más la sobrecarga del sondeo. Dos variables controlan el lado del cliente. Primero, acotar la concurrencia: el tope maxInFlight fija cuántos trabajos se rastrean a la vez, lo que mantiene estable el número de solicitudes abiertas y la memoria del cliente sin importar el tamaño del lote. Ajústalo para que coincida con el número de workers del servidor, no por encima; tener más trabajos en curso que workers solo alarga el tiempo de cola de cada trabajo. Segundo, respetar el intervalo de sondeo: cada sondeo es una lectura de estado ligera, pero un bucle cerrado multiplica el volumen de solicitudes y activa el limitador de tasa. El intervalo Retry-After de 2 segundos del servidor es el valor predeterminado correcto, y el runner lo acota a un rango de 1 a 30 segundos para que un único trabajo lento no pueda entrar en un bucle activo ni detener la ventana.

Para lotes muy grandes, procesa en ventanas (el runner usa array_chunk) en lugar de enviar todo por adelantado. Eso acota tanto el estado rastreado del cliente como la profundidad de la cola del servidor, así que un lote malformado o sobredimensionado falla dentro de una ventana en lugar de después de miles de envíos.

  • Mantén el token de portador fuera de los registros y las URL. La clave de API viaja únicamente en el encabezado Authorization. Nunca la coloques en una cadena de consulta, una línea de registro o un artefacto persistido. El runner registra el job_id y el status, nunca la credencial.
  • Deriva las rutas de salida de claves controladas por el cliente. El runner construye cada ruta de salida a partir de la clave de documento que eligió el código, unida a un directorio de salida fijo, nunca a partir de un valor en una respuesta del servidor. No interpoles un campo del trabajo en una ruta del sistema de archivos, lo que abriría un recorrido de rutas.
  • Valida los bytes descargados. El runner comprueba un 200 de /result y el encabezado %PDF antes de escribir el archivo. Un estado de descarga correcto no prueba por sí solo que el cuerpo sea un PDF.
  • Trata el resultado como no confiable hasta inspeccionarlo. Un trabajo completado significa que el servidor renderizó bytes, no que esos bytes sean seguros para reenviar. Pasa los resultados por un paso de inspección estructural antes de entregarlos a un cliente o sistema posterior.
  • Usa una clave de privilegio mínimo. La superficie de trabajos asíncronos es renderizado de nivel Core. Asigna al lote una clave limitada estrictamente a las operaciones que necesita, y rótala según el calendario que establezca la política de gestión de secretos.
  • Acota el presupuesto de sondeo. maxPolls evita que un trabajo atascado retenga al cliente para siempre. El lote registra el tiempo de espera agotado como un resultado en lugar de bloquearse, lo que evita que un trabajo defectuoso niegue el servicio al resto.

Esta recipe no hace ninguna afirmación normativa de estándares. Consume los endpoints REST de trabajos asíncronos de NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) y lee los campos del registro del trabajo que el servidor define (status, progress, error, result_url, poll_url). La comprobación del encabezado %PDF en un resultado descargado confirma únicamente que la respuesta comienza con el marcador PDF; no es una determinación de validez ni de conformidad. Para una comprobación de estándares sobre un conjunto de documentos, usa la herramienta de cumplimiento por lotes Enterprise. Consulta Comprobación de estándares por lotes sobre Connect, una superficie distinta de los trabajos de renderizado que se cubren aquí.