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.
Panel y comandos
Sección titulada «Panel y comandos»| Panel | https://console.cloud.google.com/cloudscheduler?project=api-cards-prod |
| Listar | gcloud scheduler jobs list --project=api-cards-prod --location=us-central1 |
| Pausar uno | gcloud scheduler jobs pause <name> --project=... --location=... |
| Forzar ejecución manual | gcloud scheduler jobs run <name> --project=... --location=... |
Staging
Sección titulada «Staging»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)»Sync diario del catálogo
Sección titulada «Sync diario del catálogo»| Workflow | tcgcards-api/.github/workflows/sync-incremental.yml |
| Frecuencia | 0 9 * * * (6 AM Chile) |
| Qué hace | npm 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). |
| Forzar | Botón Run workflow en GitHub Actions, o local: MYL_SYNC=true npm run sync:incremental -- --tcg=mitos (con envs R2 para imágenes) |
Jobs activos
Sección titulada «Jobs activos»mongo-backup-hourly
Sección titulada «mongo-backup-hourly»| Frecuencia | 0 * * * * (cada hora) |
| Qué hace | Gatilla 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ódigo | tcgcards-api/scripts/backup-mongo.sh + Dockerfile.backup |
| RPO efectivo | ~1 hora |
| Runbook | Ver 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.
refresh-prices-daily
Sección titulada «refresh-prices-daily»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.
| Frecuencia | 0 4 * * * (diario 4 AM Chile, America/Santiago) |
| Qué hace | Gatilla 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ódigo | tcgcards-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 / auth | OAuth con la compute SA (...-compute@, con roles/run.invoker sobre el job) |
| Idempotente | Sí — 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=tcgplayer → Mitos intocable. Validado en staging (cero inserts, sin corrupción) y dry-run contra prod.
Correr a mano / cambiar modo:
# ejecutar yagcloud 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 rechazasize>50con HTTP 400. Para actualizar el código del job hay que reconstruir la imagen desdemain(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.
auto-price-daily
Sección titulada «auto-price-daily»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.
| Frecuencia | 0 8 * * * (diario 8 AM Chile, America/Santiago) |
| Qué hace | Gatilla 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ódigo | tcgcards-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 / auth | OAuth con la compute SA (1033181994095-compute@developer.gserviceaccount.com, con roles/run.invoker sobre el job auto-price) |
| Idempotente | Sí — 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:
# ejecutar yagcloud 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-pricecorre la misma imagen de prod del serviciotcgcards-api(que ya traedist/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) tengaroles/run.invokersobre el jobauto-price(gcloud run jobs add-iam-policy-binding auto-price --member=... --role=roles/run.invoker), si no falla conPERMISSION_DENIED.
reconcile-denorm-daily
Sección titulada «reconcile-denorm-daily»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.
# ejecutar yagcloud run jobs execute reconcile-denorm --project api-cards-prod --region us-central1 --wait# ver últimas ejecucionesgcloud run jobs executions list --job reconcile-denorm --project api-cards-prod --region us-central1Heartbeat: 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).
orders-tick
Sección titulada «orders-tick»| Frecuencia | 0 * * * * (cada hora) |
| Endpoint | POST /api/v1/internal/cron/orders-tick |
| Código | src/cron/orderTickService.ts |
| Qué hace | 4 sub-tareas (ver abajo) |
| Idempotente | Sí — re-procesar es seguro |
Sub-tareas que ejecuta:
| Sub-tarea | Trigger | Acción |
|---|---|---|
processPending | Orden creada hace >72h, vendedor no aceptó | Auto-cancela + restaura stock + mail |
processAccepted | Orden aceptada hace >168h (7 días), no se envió | Auto-cancela + restaura stock + mail |
processShipped | Orden enviada hace >336h (14 días), buyer no confirmó | Auto-completa (libera pago al vendedor) + mail |
processExpiredSuspensions | Usuario con suspendedUntil vencido | Desuspende 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:
| Fase | Hitos (desde que entró a la fase) | A quién | Para |
|---|---|---|---|
pending | 24h | vendedor | aceptar la venta |
accepted | 48h y 96h | vendedor | entregar (envío o presencial) |
shipped | cada 48h (48–288h) | comprador | confirmar 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.
orders-awaiting-payment-cleanup
Sección titulada «orders-awaiting-payment-cleanup»| Frecuencia | */5 * * * * (cada 5 min) |
| Endpoint | POST /api/v1/internal/cron/orders-awaiting-payment-cleanup |
| Código | src/cron/awaitingPaymentCleanupService.ts |
| Qué hace | Cancela órdenes individuales en awaiting_payment cuyo paymentTimeoutAt ya pasó (30 min por defecto). Restaura stock. Manda mail de timeout. |
| Idempotente | Sí |
| Í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.findExpired → CheckoutService.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.
wallet-reconcile-daily
Sección titulada «wallet-reconcile-daily»| Frecuencia | 0 3 * * * (diario 3am UTC) |
| Endpoint | POST /api/v1/internal/cron/wallet-reconcile |
| Código | src/cron/walletReconcile.ts → ReconcileService.reconcileAll() |
| Qué hace | Reconcilia user.walletBalance (denormalizado) contra la suma del ledger wallet_entries. Loguea ERROR si encuentra divergencias. |
| Idempotente | Sí (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).
refunds-reconcile-daily
Sección titulada «refunds-reconcile-daily»| Frecuencia | 0 4 * * * (diario 4am UTC) |
| Endpoint | POST /api/v1/internal/cron/refunds-reconcile |
| Código | src/payments/services/RefundReconcileService.ts |
| Qué hace | Detecta órdenes canceladas con paymentId cuyo refund no se procesó correctamente. Log ERROR si encuentra inconsistencias. |
| Idempotente | Sí (read-only) |
No es un cron: alerta de uso del escáner
Sección titulada «No es un cron: alerta de uso del escáner»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.
Cómo cambiar la frecuencia de un cron
Sección titulada «Cómo cambiar la frecuencia de un cron»gcloud scheduler jobs update http <job-name> \ --schedule="*/10 * * * *" \ --project=api-cards-prod \ --location=us-central1Cambia el schedule (cron expression). Las request a la API no cambian; solo el timing.
Cómo agregar un cron nuevo
Sección titulada «Cómo agregar un cron nuevo»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.)
Cuellos de botella conocidos
Sección titulada «Cuellos de botella conocidos»Detallado en la conversación de 2026-05-20:
| Job | Estado actual | Punto de quiebre |
|---|---|---|
orders-awaiting-payment-cleanup | OK con índice nuevo | ~1.000 órdenes vencidas simultáneas |
orders-tick | OK pero usa findActiveByStatus() que trae todo a memoria | ~5.000 órdenes activas (RAM + tiempo) |
wallet-reconcile-daily | OK | ~50.000 usuarios con wallet (depende de Cloud Run timeout) |
refunds-reconcile-daily | OK | sin riesgo previsible |
mongo-backup-hourly | OK | sin riesgo (no depende de nuestro código) |
Si lo notamos crecer a esos volúmenes hay fixes preparados — ver decisiones técnicas para detalles.