Ir al contenido

Cron jobs

Las tareas programadas están en Google Cloud Scheduler (project api-cards-prod, region us-central1). Cada job hace POST a un endpoint del Cloud Run autenticado con X-Internal-Cron-Secret.

Panelhttps://console.cloud.google.com/cloudscheduler?project=api-cards-prod
Listargcloud scheduler jobs list --project=api-cards-prod --location=us-central1
Pausar unogcloud scheduler jobs pause <name> --project=... --location=...
Forzar ejecución manualgcloud scheduler jobs run <name> --project=... --location=...

Staging tiene un solo job propio: orders-awaiting-payment-cleanup-staging (*/5 * * * *, mismo endpoint pero del servicio staging, creado 2026-06-12). Sin él, las órdenes awaiting_payment de staging nunca expiraban y quedaban “Esperando pago” para siempre. El resto de los jobs (orders-tick, reconcile, backup) no existen en staging — sus efectos hay que dispararlos a mano contra el endpoint correspondiente con el INTERNAL_CRON_SECRET de staging (Secret Manager → internal-cron-secret-staging).

Crons en GitHub Actions (no Cloud Scheduler)

Sección titulada «Crons en GitHub Actions (no Cloud Scheduler)»
Workflowtcgcards-api/.github/workflows/sync-incremental.yml
Frecuencia17 9 * * * — al minuto 17 y no al 0 a propósito: al minuto cero GitHub encola las tareas programadas de media plataforma y esta se atrasaba entre una y dos horas
Qué hacenpm run sync:incremental: recorre todas las fuentes del registry (8 TCG vía TCGplayer + Mitos y Leyendas vía api.myl.cl) y actualiza sets/cartas según el cooldown por antigüedad. Para Mitos también espeja imágenes nuevas a R2. Los sets nuevos se publican atómicos (edición completa o nada).
ForzarBotón Run workflow en GitHub Actions, o local: MYL_SYNC=true npm run sync:incremental -- --tcg=mitos (con envs R2 para imágenes)
Workflowtcgcards-api/.github/workflows/mirror-set-art.yml
FrecuenciaEncadenado al sync (workflow_run sobre Sync Catalog (incremental)), no a una hora fija. Red de seguridad por horario a las 23 12 * * * por si GitHub descarta la corrida del sync. Darle “una hora más tarde” NO servía: las tareas programadas de GitHub se atrasan entre una y dos horas, así que el sync podía terminar después
Qué hacenpm run mirror:set-art: primero reconcilia cardsCount de todos los sets (ver abajo), y después, de los 24 sets más recientes, prueba hasta 10 cartas candidatas hasta juntar 3 que sirvan, verifica que las imágenes existan de verdad y las espeja en R2 en tres piezas — carta de 280px para el abanico del destacado, franja 16:10 de 440px para la baldosa, y miniatura de 64px que el destacado dibuja desenfocada de fondo. Escribe showcaseImages, showcaseTile, showcaseBackdrop, showcaseCheckedAt y showcaseCardsCount en sets.
ForzarBotón Run workflow, con opciones dry_run y force
IdempotenteSí — un set con arte y sin cambios se salta sin descargar nada

Qué rechaza (y por qué importa): el CDN de TCGplayer no avisa con errores limpios. Una imagen inexistente responde 403 con application/xml, y un producto sin arte devuelve un 200 perfectamente válido con el cartel «Image Coming Soon», que se reconoce porque mide exactamente 1000×573 — ninguna carta real mide eso. Lo que no verifica no se guarda: el set muestra el logo de su juego, nunca una imagen rota.

Por qué prueba 10 candidatas y no 3 (ago 2026): elige por precio de mercado descendente, y TCGplayer bloquea justo las imágenes de las cartas más caras. En «Vendetta» de Riftbound las tres más valiosas son “Signature” y las tres responden 403, así que el set se quedó con el logo teniendo 245 cartas con imagen buena — la cuarta servía perfecto. Ahora se baja por la lista hasta juntar tres, y el bucle corta apenas las junta: un set sano sigue descargando exactamente tres imágenes.

Por qué reconcilia el conteo acá y no solo de noche (ago 2026): este proceso está encadenado al sync, que es justo el momento en que puede nacer un set huérfano — TCGplayer le cambia el slug a un set, nace un documento nuevo, las cartas se mudan y el viejo queda con su cardsCount congelado. El reconciliador nocturno es un Cloud Run Job con su propio horario, en otro sistema, y no se puede encadenar a un workflow de GitHub: hoy corre antes del sync, así que un huérfano viviría casi un día entero y podía llegar a ser el destacado de la portada llevando a una página vacía. Corregirlo acá cierra la ventana. La familia nocturna se queda como red; es idempotente.

Cómo un set pasa de logo a carta, solo: se reintenta cualquier set que no tenga sus 3 imágenes, así que el arte aparece al día siguiente de existir arriba. Y se guarda showcaseCardsCount: mientras la cantidad de cartas del set siga cambiando, se vuelve a elegir — un set entra a la portada con 10 cartas cargadas y termina con 200, y las tres mejores de diez casi nunca son las representativas. Cuando deja de crecer, deja de tocarse solo.

Va en GitHub y no en Cloud Scheduler por dos razones: las credenciales de R2 viven como secretos de GitHub (no hay ninguna en Secret Manager), y los Cloud Run Jobs fijan la imagen, así que cada despliegue que tocara este código obligaría a actualizarla a mano o el proceso quedaría corriendo código viejo en silencio.

Sin alerta de heartbeat, a diferencia de auto-price y refresh-prices: si se detiene, el arte se congela y los sets nuevos muestran su logo. Molesto, no grave.

Frecuencia0 * * * * (cada hora)
Qué haceGatilla el Cloud Run Job mongo-backup: corre mongodump --archive --gzip y sube el dump a gs://tcgcards-mongo-backups/ (retención 30 días)
Códigotcgcards-api/scripts/backup-mongo.sh + Dockerfile.backup
RPO efectivo~1 hora
RunbookVer runbook restore

Es una segunda capa independiente de los snapshots diarios nativos de Atlas (esos los gestiona Atlas; ver infra). El dump en GCS vive fuera de Atlas, así que sobrevive aunque pase algo con la cuenta/cluster.

Refresca los precios de referencia de TCGplayer (cards.marketPrices, en USD) para mantenerlos frescos (≤24h). Mismo patrón que el backup: el Scheduler gatilla un Cloud Run Job, no un endpoint HTTP.

Frecuencia0 4 * * * (diario 4 AM Chile, America/Santiago)
Qué haceGatilla el Cloud Run Job refresh-prices en modo backfill: recorre los 1.526 sets de TCGplayer (oldest-first por sets.pricesRefreshedAt), baja precios de mp-search-api.tcgplayer.com (50/página, una petición cada 300 ms) y hace $set de marketPrices en las cartas (un bulkWrite por set). ~1h13, con un límite duro de 3 h (timeoutSeconds: 10800).
Códigotcgcards-api/src/jobs/refresh-prices.ts + src/services/PriceRefreshEngine.ts
CostoEs el mayor consumidor de Cloud Run del proyecto. A 1 req/s tardaba 2h13 y se llevaba el 64% (~225.000 vCPU-s/mes, contra 91.000 del API de producción). A 300 ms baja a ~120.000. El tramo gratuito son 180.000 vCPU-s/mes compartidos por toda la cuenta.
SA / authOAuth con la compute SA (...-compute@, con roles/run.invoker sobre el job)
IdempotenteSí — re-correr solo refresca de nuevo (escritura quirúrgica)

Seguridad (write a ~200k docs): escribe SOLO marketPrices vía bulkWrite ($set, sin upsert → no inserta, sin deletes, skip-null → no pisa un precio bueno con null), filtra source=tcgplayerMitos intocable. Validado en staging (cero inserts, sin corrupción) y dry-run contra prod.

Correr a mano / cambiar modo:

Ventana de terminal
# ejecutar ya
gcloud run jobs execute refresh-prices --project=api-cards-prod --region=us-central1
# cambiar modo (backfill = todo; sample = 1/TCG; --dry-run = no escribe; page-size MÁX 50)
gcloud run jobs update refresh-prices --project=api-cards-prod --region=us-central1 \
--args "dist/jobs/refresh-prices.js,--mode=backfill,--page-size=50,--min-interval-ms=300"

OJO pageSize ≤ 50: TCGplayer rechaza size>50 con HTTP 400.

El ritmo contra TCGplayer (--min-interval-ms)

Sección titulada «El ritmo contra TCGplayer (--min-interval-ms)»

El intervalo entre peticiones es nuestro, no un límite de TCGplayer. Vivía fijo en 1 s dentro del código; desde 2026-08-15 se configura por argumento, así que subirlo o bajarlo es este jobs update: no hay que recompilar ni desplegar.

Medido contra su buscador (2026-08-15): responde en ~550 ms, y aguantó ráfagas a 2/s, 4/s y 8/s sin un solo rechazo. Como el recorrido es secuencial, por debajo de ~550 ms bajar el número ya no acelera — el cuello pasa a ser su tiempo de respuesta. Pedir 300 ms da ~1,8 peticiones/s reales, y ahí se acaba la ganancia fácil: para más habría que pedir en paralelo, que es otro cambio.

Piso de 200 ms, por si alguien escribe --min-interval-ms=3 en vez de =300. Como por debajo de ~550 ms no se gana velocidad, el piso no cuesta nada.

Cómo saber si te pasaste. La línea refresh-prices finished del log trae:

minIntervalMs: 300
tcgplayer: { throttled: 0, transient: 0 }

throttled cuenta los HTTP 429 — TCGplayer diciéndonos que vamos muy rápido. transient cuenta 502/503/504, que son ruido de pasarela y no dicen nada sobre el ritmo. Si throttled sube de 0 sale además un warn aparte, y toca volver a subir el intervalo.

Sets huérfanos: por qué el job se puso lento (jul 2026)

Sección titulada «Sets huérfanos: por qué el job se puso lento (jul 2026)»

TCGplayer renombra sets cada tanto (SandstormEX Sandstorm, Crown ZenithSWSH: Crown Zenith). Cuando lo hace, el sync crea el nombre nuevo e importa sus cartas —que se mudan, porque el id de carta es tcg-productId— y el set viejo queda en la base para siempre: SyncEngine nunca borra lo que desaparece de la lista remota.

El problema es lo que hace TCGplayer con ese nombre viejo:

Filtro enviadoResultados
setName=sandstorm29.629
setName=ex-sandstorm100 ✅
sin filtro de set29.629

No devuelve un error: descarta el filtro y manda el juego completo. El job entonces pagina ~198 veces (tope 9.900 ÷ 50) por un set del que no actualiza nada.

Pasó dos veces en silencio —1 may (2 huérfanos) y 25 jul (15 más)—. Con 20 huérfanos el trabajo casi se duplicaba (≈3.960 peticiones inútiles contra ≈4.600 reales), el job cruzó su límite de 3 h y el 30 y 31 de julio lo mataron antes de registrar su latido: por eso saltó la alerta.

Guard (desde 2026-07-31): si la primera página trae productos de varios sets distintos, el filtro fue ignorado → el set se omite tras 1 petición. Se cuenta como setsSkippedUnrecognized en el log de cierre, no como fallo: el set no se marca como refrescado (sería mentir) pero tampoco alimenta el circuit breaker, porque veinte huérfanos abortarían la corrida de los ~1.500 sanos.

Limpiar los que ya existan: scripts/purge-orphan-tcgplayer-sets.ts. Sin --confirm no escribe nada; exige cuatro condiciones a la vez —una verificada en vivo contra TCGplayer—, guarda copia de los documentos en backups/ y comprueba que el total de cartas no cambie.

Histórico: este refresco vivió un tiempo como workflow de GitHub Actions (refresh-prices.yml), pero se movió a Cloud Run Job porque en repo privado GH Actions costaba ~$5-13/mes y se estimó que en Cloud Run cabría gratis. Esa estimación no se cumplió (ver la fila «Costo» arriba): igual sigue saliendo más barato que GH Actions, pero no es gratis. No reactivar ese workflow (sería doble refresco). Ver decisiones.

Recalcula los precios de las publicaciones de los vendedores con Precios dinámicos activos (la marca visible de lo que internamente se llama autoPrice/autoPricing). Es opt-in: el vendedor elige que sus cartas de TCGplayer se repricen solas cada día. Mismo patrón que el backup y refresh-prices: el Scheduler gatilla un Cloud Run Job, no un endpoint HTTP. Creado 2026-06-30.

Frecuencia0 8 * * * (diario 8 AM Chile, America/Santiago)
Qué haceGatilla el Cloud Run Job auto-price: recalcula precio_CLP = round100(market_USD × usdRate) para las publicaciones de los vendedores que activaron Precios dinámicos (cada vendedor con su propio usdRate). Corre después de refresh-prices (4 AM) a propósito, para trabajar con las referencias de TCGplayer ya frescas del día. Con 0 vendedores activados es no-op.
Códigotcgcards-api/src/jobs/auto-price.ts + src/jobs/auto-price.run.ts + src/listings/autoPricePlan.ts
Costo$0 (cabe en el free tier de Cloud Run)
SA / authOAuth con la compute SA (1033181994095-compute@developer.gserviceaccount.com, con roles/run.invoker sobre el job auto-price)
IdempotenteSí — escritura quirúrgica con guard CAS {status:active, autoPrice:true} (re-correr re-calcula sobre las mismas referencias)

Seguridad (no toca el flujo de pago): solo cartas de catálogo de TCGplayer (card.source == "tcgplayer") → Mitos (api.myl.cl), sellado, accesorios y cartas custom nunca se repricen. Gate de frescura 26h: el set (sets.pricesRefreshedAt) y la carta (cards.marketPrices.pricesUpdatedAt) deben estar frescos; si no, se salta (no_fresh_price) y no se pisa un precio con una referencia rancia. El precio nuevo es round100 (redondeo half-up al múltiplo de $100 más cercano, aritmética entera) y respeta el mínimo de publicación $50. La escritura usa guard CAS {status:active, autoPrice:true}, así que no pisa una venta o edición manual ocurrida entre la lectura y la escritura. No toca el flujo de pago: las órdenes congelan el precio al crearse.

Heartbeat: cada corrida loguea auto-price run OK (con candidates/applied); esa línea es la señal de vida del job (si no aparece en 26-48h, el Scheduler murió y los precios quedaron congelados).

Correr a mano / flags:

Ventana de terminal
# ejecutar ya
gcloud run jobs execute auto-price --project=api-cards-prod --region=us-central1
# flags opcionales del entrypoint: --dry-run (no escribe), --seller=<id> (acota a un vendedor)
gcloud run jobs update auto-price --project=api-cards-prod --region=us-central1 \
--args "dist/jobs/auto-price.js,--dry-run"

Misma imagen que el API: el Job auto-price corre la misma imagen de prod del servicio tcgcards-api (que ya trae dist/jobs/auto-price.js), así que se actualiza solo cuando se redespliega el API.

OJO permisos: el Scheduler necesita que la compute SA (1033181994095-compute@developer.gserviceaccount.com) tenga roles/run.invoker sobre el job auto-price (gcloud run jobs add-iam-policy-binding auto-price --member=... --role=roles/run.invoker), si no falla con PERMISSION_DENIED.

Diario 05:00 America/Santiago (antes de refresh-prices y auto-price). Corre el Cloud Run Job reconcile-denorm (npm run reconcile-denorm): audita y corrige las CUATRO familias de datos denormalizados — (a) resúmenes de stock en cards vs sus listings reales, (b) las 7 copias denormalizadas en listings vs su carta, (c) soldCount/lastSoldAt de listings vs las órdenes (contador incremental: un desvío no se autocorrige solo — jul 2026), (d) cardsCount de cada set vs sus cartas reales (ago 2026 — ver abajo). Noches sanas = 0 escrituras; si corrige >0 emite un logger.warn con conteos → investigar la causa (un hook que no disparó, una corrección del sync upstream). Idempotente; --dry-run disponible para auditoría manual.

Ventana de terminal
# ejecutar ya
gcloud run jobs execute reconcile-denorm --project api-cards-prod --region us-central1 --wait
# ver últimas ejecuciones
gcloud run jobs executions list --job reconcile-denorm --project api-cards-prod --region us-central1

Familia (d), cardsCount de los sets (ago 2026): el slug de un set de TCGplayer es su urlValue y el _id se arma con él, así que cuando TCGplayer renombra un set nace un documento NUEVO, las cartas se mudan al nuevo y el viejo queda huérfano con su conteo congelado — el sync no lo corrige jamás porque solo visita los sets que TCGplayer lista hoy. Eso puso un set con cero cartas como destacado de «Nuevos lanzamientos», duplicando al set real y llevando a una página vacía. Con el conteo verdadero el huérfano queda en 0 y desaparece solo de la portada y del catálogo. No borra nada: el documento queda por si TCGplayer vuelve al slug anterior. Primera corrida en prod (ago 2026): 1.642 sets revisados, 93 conteos corregidos, 2 huérfanos.

Heartbeat: loguea reconcile-denorm run OK SOLO en corrida real exitosa → métrica reconcile_denorm_heartbeat + política dead-man’s switch (~30h) al correo (patrón clonado de auto-price). El dry-run loguea otro mensaje y NO late.

⚠️ GOTCHA de imágenes de jobs (aplica a TODOS los Cloud Run Jobs): los jobs fijan su imagen al crearse — el deploy del servicio NO los actualiza. Si un deploy cambia lógica que un job comparte (p. ej. los hooks de recálculo que usa auto-price), hay que actualizar la imagen del job: gcloud run jobs update <job> --image <imagen-del-deploy>. Descubierto en el rollout de perf-escala (2026-07-03).

Frecuencia0 * * * * (cada hora)
EndpointPOST /api/v1/internal/cron/orders-tick
Códigosrc/cron/orderTickService.ts
Qué hace4 sub-tareas (ver abajo)
IdempotenteSí — re-procesar es seguro

Sub-tareas que ejecuta:

Sub-tareaTriggerAcción
processPendingOrden creada hace >72h, vendedor no aceptóAuto-cancela + restaura stock + mail
processAcceptedOrden aceptada hace >168h (7 días), no se envióAuto-cancela + restaura stock + mail
processShippedOrden enviada hace >336h (14 días), buyer no confirmóAuto-completa (libera pago al vendedor) + mail
processExpiredSuspensionsUsuario con suspendedUntil vencidoDesuspende automáticamente

Warning de 24h antes: para cada deadline, manda email “se cancelará/recibirá en ~24h” cuando faltan menos de 24h.

Recordatorios proactivos (campo reminderCount en la orden, idempotente vía CAS claimReminder): además del aviso SLA, en la rama intermedia de cada fase el cron empuja recordatorios:

FaseHitos (desde que entró a la fase)A quiénPara
pending24hvendedoraceptar la venta
accepted48h y 96hvendedorentregar (envío o presencial)
shippedcada 48h (48–288h)compradorconfirmar recepción (libera el pago al vendedor)

Se cortan apenas el actor avanza la orden (reminderCount se resetea a 0 en cada transición — accept/ship). Son transaccionales (Resend, no pasan por preferencias) como el resto del ciclo de orden; el email es fire-and-forget. Template: notifications/templates/orderReminder.ts.

Métodos de entrega (jul 2026): cuando la orden es a retiro, los recordatorios accept/deliver1/deliver2 (al vendedor) nombran el punto de retiro por su título (Order.pickupPoint.title, ej. “acéptala para que el comprador pueda retirar su pedido en tu punto de retiro «X»”); el recordatorio receive (al comprador) ajusta el copy a “retiraste” en vez de “recibiste”.

Frecuencia*/5 * * * * (cada 5 min)
EndpointPOST /api/v1/internal/cron/orders-awaiting-payment-cleanup
Códigosrc/cron/awaitingPaymentCleanupService.ts
Qué haceCancela órdenes individuales en awaiting_payment cuyo paymentTimeoutAt ya pasó (30 min por defecto). Restaura stock. Manda mail de timeout.
Idempotente
Índice clave{status:1, paymentTimeoutAt:1} en orders (agregado 2026-05-20)

También maneja orphan pending orders: órdenes que quedaron en estado pending sin paymentId por crash del proceso entre create() y updateById(). Las cancela si llevan >60 min.

Carrito multi-vendedor: las dos queries de barrido (timeout y orphan) filtran checkoutId: null, así que no tocan sub-órdenes de checkout (que legítimamente tienen paymentId: null) — cancelarlas sin reembolsar sería pérdida de plata. Los checkouts vencidos se expiran aparte, vía checkoutRepo.findExpiredCheckoutService.failCheckout, que cancela cada sub-orden aún en awaiting_payment, restaura su stock, marca el checkout expired y manda un mail consolidado checkoutFailed. Todo idempotente.

Frecuencia0 3 * * * (diario 3am UTC)
EndpointPOST /api/v1/internal/cron/wallet-reconcile
Códigosrc/cron/walletReconcile.tsReconcileService.reconcileAll()
Qué haceReconcilia user.walletBalance (denormalizado) contra la suma del ledger wallet_entries. Loguea ERROR si encuentra divergencias.
IdempotenteSí (read-only)

Si encuentra una divergencia, escribe log estructurado [wallet-reconcile] DIVERGENCES DETECTED que debe disparar alerta en Sentry. La acción de corrección es manual (endpoint /admin/reconcile o intervención de un dev).

Frecuencia0 4 * * * (diario 4am UTC)
EndpointPOST /api/v1/internal/cron/refunds-reconcile
Códigosrc/payments/services/RefundReconcileService.ts
Qué haceDetecta órdenes canceladas con paymentId cuyo refund no se procesó correctamente. Log ERROR si encuentra inconsistencias.
IdempotenteSí (read-only)
Frecuencia5 0 * * * America/Santiago (diario 00:05 hora de Chile)
EndpointPOST /api/v1/internal/cron/portfolio-snapshot
Códigosrc/portfolio/services/PortfolioService.ts (snapshotDiario)
Qué haceEl historiador del Portafolio: congela el valor publicado de cada vendedor (Σ precio × cantidad de listings activos, en centavos) como el punto del día. Fotografía a quien tiene activos o a quien su último snapshot era > 0 — así la caída a cero queda registrada el día real.
IdempotenteSí (upsert sobre índice único {sellerId, date}; re-corridas el mismo día actualizan el valor, incluida la caída a cero intradía)

Medido al diseñarlo (2026-08-18): la agregación completa tarda 277 ms con 3.435 publicaciones activas y 131 vendedores. Escala con las publicaciones activas.

Creado el 2026-08-19 y probado con gcloud scheduler jobs run: la primera corrida escribió 131 snapshots verificados en la base. La cabecera X-Internal-Cron-Secret va literal en el scheduler — con este ya son 10 copias del secreto (actualizar el conteo del runbook al rotarlo). El horario es 01:05 y no 00:05 a propósito: el salto al horario de verano chileno elimina la hora 00 un domingo al año y Cloud Scheduler puede omitir esa corrida.

La alerta “OCR al 80% del límite diario” no es un job de Cloud Scheduler. Se dispara inline dentro de POST /api/v1/scan/identify-image: en cada scan se incrementa el contador del día (scanner_usage) y, si pasó el umbral (400/500) y aún no se alertó hoy (alertedAt80), se manda el email al admin. Por eso no aparece en la lista de jobs ni necesita Cloud Scheduler. El contador en scanner_usage resetea por fecha UTC (no hay job de reset). Código: src/scanner/services/ScannerAlertService.ts.

Ventana de terminal
gcloud scheduler jobs update http <job-name> \
--schedule="*/10 * * * *" \
--project=api-cards-prod \
--location=us-central1

Cambia el schedule (cron expression). Las request a la API no cambian; solo el timing.

Ventana de terminal
gcloud scheduler jobs create http nuevo-cron \
--project=api-cards-prod \
--location=us-central1 \
--schedule="0 * * * *" \
--time-zone="UTC" \
--uri="https://tcgcards-api-1033181994095.us-central1.run.app/api/v1/internal/cron/nuevo-cron" \
--http-method=POST \
--oidc-service-account-email=mongo-backup-scheduler@api-cards-prod.iam.gserviceaccount.com \
--headers="X-Internal-Cron-Secret=<valor del secret>"

(Antes de crear, hay que implementar el endpoint en el código + redeploy.)

Detallado en la conversación de 2026-05-20:

JobEstado actualPunto de quiebre
orders-awaiting-payment-cleanupOK con índice nuevo~1.000 órdenes vencidas simultáneas
orders-tickOK pero usa findActiveByStatus() que trae todo a memoria~5.000 órdenes activas (RAM + tiempo)
wallet-reconcile-dailyOK~50.000 usuarios con wallet (depende de Cloud Run timeout)
refunds-reconcile-dailyOKsin riesgo previsible
mongo-backup-hourlyOKsin riesgo (no depende de nuestro código)

Si lo notamos crecer a esos volúmenes hay fixes preparados — ver decisiones técnicas para detalles.