-- =====================================================================
-- Reanudación automática de schedules pausados por eliminación
-- Fecha: 2026-08 (aplícalo DESPUÉS de 2026-08_backups_tracking.sql)
--
-- Todo el archivo es re-ejecutable de forma segura: las columnas se
-- agregan con verificación previa contra information_schema y el backfill
-- solo toca filas con paused_reason IS NULL.
--
-- Agrega las dos columnas que necesita el predicado de reanudación que
-- implementa client_bkps (dispatch/resume.go):
--
--   reanudar  sii  paused = 1
--             AND  paused_reason = 'instance_deleted'
--             AND  last_ready_datetime > paused_datetime
--
-- Motivo: al eliminar una instancia el portal ya manda pausar su schedule
-- de Velero y client_bkps lo aplica, pero nada lo reanuda cuando la instancia se vuelve a
-- desplegar o se restaura desde un backup. El schedule queda pausado
-- indefinidamente y la instancia corre con datos y sin puntos de
-- restauración vigentes (verificado en staging: >24h en ese estado).
--
-- Por qué hacen falta las dos columnas y no basta con lo que ya existe:
--
--   * `paused` solo dice que está pausado, no por qué. Los motivos de
--     pausa no son excluyentes en el tiempo: hoy la única pausa que
--     existe es la que dispara el portal al eliminar la instancia, pero
--     está previsto que el operador pueda pausar a propósito. Sin
--     `paused_reason`, reanudar automáticamente todo lo pausado
--     reactivaría también las pausas deliberadas.
--
--   * `requests_queue.status` es un chequeo de nivel ("está listo"), no
--     de evento. El predicado compara CUÁNDO quedó listo contra CUÁNDO
--     se pausó, y eso requiere una marca de tiempo: una instancia que
--     nunca dejó de estar lista no debe disparar nada, y una que quedó
--     lista DESPUÉS de la pausa sí.
--
-- Contrato de escritura (fuera de este repo, se documenta aquí porque el
-- esquema por sí solo no lo impone):
--
--   * `paused_reason` lo escribe client_bkps junto con `paused` y
--     `paused_datetime` al pausar (dispatch/operations.go,
--     applySchedulePaused). El portal NO escribe en backup_schedule:
--     pide la pausa y client_bkps la aplica. Al REANUDAR (paused = 0)
--     la misma función debe devolver `paused_reason` a NULL: un
--     `paused_reason` viejo sobreviviendo a la reanudación haría que una
--     pausa manual posterior que olvide escribir el motivo herede
--     'instance_deleted' y sea reanudada automáticamente — justo lo que
--     esta columna existe para evitar.
--
--   * `last_ready_datetime` lo escribe k8s_monitor_services cada vez que
--     detecta que los pods del release quedaron listos, y SIEMPRE se
--     sobreescribe. Es lo contrario de `supply_datetime`, que es
--     write-once (primer aprovisionamiento): un redespliegue o un
--     restore posterior tiene que mover esta marca hacia adelante o el
--     predicado no vuelve a dispararse nunca.
--
-- Ojo con los relojes: el predicado compara dos datetime escritos por
-- procesos distintos (client_bkps vs k8s_monitor_services). Si uno
-- guarda UTC y el otro hora local del servidor, la comparación falla en
-- silencio — ni error ni fila mal formada, simplemente nunca reanuda (o
-- reanuda de más).
--
-- El camino escritor -> DAO -> MySQL ya está verificado y NO desplaza el
-- valor (clnt_bkps_fix_restores, 2026-08-27): con la hora real en
-- 00:58:58 UTC, el `synced_at` que acababa de escribir el loop de sync
-- se leyó como 00:53:31, o sea a 5m27s — el intervalo del propio loop.
-- Un corrimiento de zona horaria en el trayecto habría dado ~6h, no 5
-- minutos. La columna DATETIME pierde la "Z", no el valor.
--
-- El FORMATO de lectura tampoco es un riesgo: da igual con qué separador
-- se haya escrito (espacio o "T"), lo que sale del DAO siempre es
-- ISO-8601 con "T". No es casualidad ni depende del writer — el valor
-- vuelve de EF Core como DateTime (el texto guardado ya quedó atrás al
-- parsear la columna DATETIME) y DynamicCrudService lo serializa con
-- System.Text.Json (ToProtoValue, server/Services/DynamicCrudService.cs),
-- que emite el formato round-trip. Observado además en vivo contra
-- service_endpoints.synced_at, escrito con separador espacio y leído con
-- "T" (clnt_bkps_fix_restores, 2026-08-27). Lo que sí se pierde es el
-- offset: DATETIME no lo guarda, así que el "UTC" es una convención
-- entre escritores, no algo que el tipo imponga — de ahí lo que sigue.
--
-- Lo que queda por garantizar es cada escritor en su propio código:
-- `time.Now().UTC()` explícito, y no depender de que el contenedor no
-- tenga tzdata configurado. Eso último da la hora correcta por accidente
-- y se rompe en silencio el día que alguien instale tzdata por otra
-- razón — aplica en particular a la escritura nueva de
-- `last_ready_datetime`.
--
-- ---------------------------------------------------------------------
-- ORDEN DE DESPLIEGUE — no es intercambiable, y una inversión rompe
-- mucho más que la feature de auto-resume.
--
--   1. Aplicar este SQL (las dos columnas existen en la tabla).
--   2. Desplegar el build del DAO que mapea las propiedades nuevas
--      (BackupSchedule.PausedReason, RequestQueue.LastReadyDatetime).
--   3. Recién entonces autorizar los builds que las escriben o leen:
--      k8s_monitor_services (last_ready_datetime), client_bkps
--      (predicado de resume), portal (paused_reason al pausar).
--
-- Por qué 1 antes que 2 — el paso que se pasa por alto, y el peor de los
-- dos. Las lecturas de este DAO materializan la entidad completa
-- (BaseRepository: _dbSet.ToListAsync() / FindAsync(), sin proyección
-- SQL; el parámetro `columns` recorta DESPUÉS de materializar). O sea EF
-- Core emite SELECT con TODAS las propiedades mapeadas. Un build del DAO
-- con estas propiedades contra una base sin las columnas hace fallar
-- toda lectura de `requests_queue` y `backup_schedule` con "Unknown
-- column", no solo las escrituras del campo nuevo. Aplicar este SQL solo
-- (paso 1 sin paso 2) es inofensivo: dos columnas nullable que ningún
-- código todavía nombra.
--
-- Alcance concreto de esa inversión, para dimensionar la ventana: las
-- dos tablas no son de uso interno de esta feature. client_bkps
-- (go-clnt-bkps-daas) las consulta en sus cuatro loops — sync de
-- schedules cada 5 min, provisioning, restores, update de schedules
-- (store/backup_sync.go, store/queries.go, verificado por
-- clnt_bkps_fix_restores) — y el portal las lee por su lado. Un build
-- del DAO adelantado al SQL no degrada una feature nueva: deja sin
-- sincronizar, sin provisionar y sin despachar restores a servicios que
-- ya están en producción, con "Unknown column" en cada ciclo hasta
-- revertir o aplicar el SQL.
--
-- Y el alcance no se queda en backups. Según el inventario de sus
-- propios call sites que reportó k8s_monitor_fix_restores (seis usos de
-- resolveRequestQueueRow, todos resolviendo la fila de `requests_queue`
-- ANTES de escribir — dato de ese repo, no verificable desde aquí, pero
-- consistente con el mecanismo de lectura descrito arriba), la misma
-- ventana deja caer:
--
--   * alta de releases            (store/queue.go, UpdateRecordStatusRequestQueue)
--   * registro de fallos de alta  (store/queue.go, UpdateErrorRequestQueue)
--   * consumption_history         (store/consumption.go, insert y cierre)
--   * service_endpoints           (store/serviceendpoints.go)
--
-- El tercero es el que cambia la naturaleza del riesgo: consumo que no
-- se escribe es consumo que no se factura, y a diferencia del resto no
-- se recupera solo al revertir el build — las ventanas de consumo que
-- pasaron sin registrarse hay que reconstruirlas a mano. Quien decida la
-- ventana de despliegue debería verlo como riesgo de facturación y de
-- alta de servicios, no como riesgo de la feature de backups.
--
-- Por qué 3 después que 2. DynamicCrudService valida los campos del
-- request contra el MODELO EF, no contra la tabla (ValidateDataFields,
-- server/Services/DynamicCrudService.cs): un campo que el modelo no
-- tiene hace que el Update entero devuelva error ANTES de tocar la BD.
-- Para k8s_monitor_services eso no degrada solo los backups: la misma
-- llamada (UpdateRecordStatusRequestQueue) es la que marca
-- service_status_id = 4 cuando un release queda listo, así que ningún
-- servicio nuevo pasaría a Running hasta revertir el build.
--
-- Consecuencia práctica: aplicar esta migración NO es por sí sola la luz
-- verde para los otros repos. La luz verde es el paso 2 desplegado.
-- ---------------------------------------------------------------------
-- =====================================================================

-- ---------------------------------------------------------------------
-- 1. backup_schedule — motivo de la pausa
-- ---------------------------------------------------------------------
-- Con verificación previa para que el archivo sea re-ejecutable, igual
-- que 2026-08_service_endpoints.sql. Reaplicar un ALTER desnudo sobre una
-- base ya migrada aborta con "Duplicate column name" y deja al que opera
-- sin saber si el error es el esperado o uno real.
SET @ddl := (
  SELECT IF(COUNT(*) = 0,
    'ALTER TABLE `backup_schedule` ADD COLUMN `paused_reason` varchar(30) DEFAULT NULL AFTER `paused`',
    'DO 0')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'backup_schedule' AND COLUMN_NAME = 'paused_reason');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- Backfill: marcar como 'instance_deleted' lo que ya está pausado.
--
-- Es seguro hoy porque la eliminación de instancia desde el portal es la
-- ÚNICA ruta que pausa un schedule (backup_operations.operation = 'pause'
-- solo se genera ahí); la pausa manual del operador todavía no existe.
-- Confirmado por frnt_fix_restores del lado del portal y por
-- clnt_bkps_fix_restores del lado del worker que la aplica. Además es
-- necesario: sin este UPDATE los schedules que ya llevan tiempo pausados
-- quedan con paused_reason NULL, el predicado no los toma, y las 17
-- filas que hoy están pausadas en staging seguirían sin reanudar nunca.
--
-- Si para cuando se aplique esta migración ya existe la pausa manual,
-- ACOTAR este UPDATE antes de ejecutarlo (por ejemplo, excluyendo los
-- schedules cuya última operación 'pause' en backup_operations tenga
-- requested_by_user_id NOT NULL).
--
-- Comprobación previa sugerida:
--   SELECT bs.`BackupScheduleID`, bs.`paused_datetime`,
--          bo.`requested_by_user_id`
--   FROM `backup_schedule` bs
--   LEFT JOIN `backup_operations` bo
--          ON bo.`backup_schedule_id` = bs.`BackupScheduleID`
--         AND bo.`operation` = 'pause'
--   WHERE bs.`paused` = b'1';
UPDATE `backup_schedule`
SET `paused_reason` = 'instance_deleted'
WHERE `paused` = b'1'
  AND `paused_reason` IS NULL;

-- ---------------------------------------------------------------------
-- 2. requests_queue — última vez que los pods quedaron listos
-- ---------------------------------------------------------------------
SET @ddl := (
  SELECT IF(COUNT(*) = 0,
    'ALTER TABLE `requests_queue` ADD COLUMN `last_ready_datetime` datetime DEFAULT NULL AFTER `supply_datetime`',
    'DO 0')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'requests_queue' AND COLUMN_NAME = 'last_ready_datetime');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- Sin backfill a propósito: NULL significa "todavía no se ha observado
-- un ready desde que existe la columna", y con NULL el predicado
-- (last_ready_datetime > paused_datetime) es falso, así que no reanuda
-- nada. Copiar `supply_datetime` aquí sería falsear un evento que nadie
-- observó y podría reanudar un schedule cuya instancia sigue eliminada.
-- La primera escritura real de k8s_monitor_services llena el dato.

-- Verificación:
-- SELECT `paused`, `paused_reason`, COUNT(*)
--   FROM `backup_schedule` GROUP BY `paused`, `paused_reason`;
-- SELECT COUNT(*) FROM `requests_queue` WHERE `last_ready_datetime` IS NOT NULL;

-- =====================================================================
-- ROLLBACK (comentado)
-- =====================================================================
-- ALTER TABLE `backup_schedule` DROP COLUMN `paused_reason`;
-- ALTER TABLE `requests_queue`  DROP COLUMN `last_ready_datetime`;
