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
Frecuencia0 9 * * * (6 AM Chile)
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)
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.469 sets de TCGplayer (oldest-first por sets.pricesRefreshedAt), baja precios de mp-search-api.tcgplayer.com (50/página, 1 req/s) y hace $set de marketPrices en las cartas (un bulkWrite por set). ~90 min.
Códigotcgcards-api/src/jobs/refresh-prices.ts + src/services/PriceRefreshEngine.ts
Costo$0 (cabe en el free tier de Cloud Run; el API usa solo ~22%)
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"

OJO pageSize ≤ 50: TCGplayer rechaza size>50 con HTTP 400. Para actualizar el código del job hay que reconstruir la imagen desde main (gcloud run jobs deploy refresh-prices --source .).

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 Cloud Run sale 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 TRES 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). 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

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.

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)

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.