fix(profile): el reduce va por lotes y un run a medias es reanudable - #243
Merged
Conversation
El reduce pasaba TODOS los candidatos de un run en una sola llamada contra un validador que exige particion perfecta: cada candidate_id exactamente una vez, sin duplicar ni omitir. Eso escala hasta donde el modelo sostiene la particion y no mas. El run 2 completo con 68 candidatos; el run 3 acumulo 234 y quedo clavado en `reducing` desde el 2026-08-20 — diez dias de corridas programadas, mas cuatro reintentos medidos a mano, todos fallando entre "candidates must appear exactly once" y JSON malformado. El perfil inyectado en cada SessionStart quedo con hechos del 6 al 9 de agosto. Lotear sin marcar consumidos habria sido PEOR que el bloqueo: `apply_decisions` relee los candidatos del run en cada llamada, asi que un lote aplicado y un crash antes del siguiente dejaba los mismos candidatos listos para aplicarse de nuevo — hechos duplicados en el perfil. Por eso la columna va primero. - Migracion 23: `compiled_profile_candidates.consumed_at`, sin unicidad, con indice (run_id, consumed_at). Las filas previas quedan NULL a proposito: un run en vuelo DEBE ver sus candidatos como pendientes. - `candidates(run_id, pending_only=True)` filtra lo ya consumido. - `apply_decisions` marca consumido DENTRO de la misma transaccion que aplica la decision, protegido con `AND consumed_at IS NULL`. - `_reduce_and_complete` loopea sobre lotes de `reduce_batch_size` (40 por defecto, `MEMORYMASTER_PROFILE_REDUCE_BATCH`), releyendo `active_facts()` entre lotes para que el lote N+1 pueda fusionar contra los hechos del N. - Un lote que no consume nada corta con error en vez de loopear infinito. Semantica: las fusiones se deciden con visibilidad parcial, asi que dos candidatos de lotes distintos pueden quedar como dos hechos donde una particion unica los unia. Es el precio, y es preferible a no reducir nunca. MEDIDO EN LA BASE VIVA: el primer intento consumio 120/234 y fallo en el cuarto lote — y los tres lotes previos QUEDARON. Antes ese mismo fallo tiraba todo, que es por que diez noches seguidas terminaron igual. El reintento siguiente dreno los 114 restantes y el run 3 quedo en `completed`. Hechos activos 29 -> 52. Techo del perfil 800/40 -> 1400/60 en el mismo commit: con 52 hechos el techo viejo cortaba 20 en silencio y la proyeccion quedaba clavada en 800/800, justo cuando los hechos NUEVOS eran los buenos (memorymaster+UNMS sup=10, WISP, Task Scheduler) y los viejos los stale. La proyeccion pasa de 32 a 50 vinietas. Barra roja verificada: revirtiendo solo el filtro de pendientes, la suite se cuelga hasta el timeout en vez de fallar un assert — el bucle relee los mismos 234 para siempre. Ese es el modo de falla que el filtro evita. 49/49 en los tests de perfil, ruff limpio, inyeccion en vivo confirmada.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
El bloqueo
El reduce pasaba todos los candidatos de un run en una sola llamada, contra un validador (
providers.py:108) que exige partición perfecta: cadacandidate_idexactamente una vez, sin duplicar ni omitir.reducingdesde el 2026-08-20Diez días de corridas programadas más cuatro reintentos medidos a mano, alternando entre
profile candidates must appear exactly oncey JSON malformado. El perfil inyectado en cada SessionStart quedó con hechos del 6 al 9 de agosto.Por qué la columna va primero
Lotear sin marcar consumidos habría sido peor que el bloqueo:
apply_decisionsreleecandidates(run_id)entero en cada llamada (repository.py:296), así que un lote aplicado y un crash antes del siguiente dejaba los mismos candidatos listos para aplicarse de nuevo — hechos duplicados en el perfil que entra en cada sesión.compiled_profile_candidates.consumed_at, sin unicidad, índice(run_id, consumed_at). Filas previas en NULL a propósito: un run en vuelo debe ver sus candidatos como pendientes.candidates(run_id, pending_only=True)filtra lo consumido.apply_decisionsmarca dentro de la misma transacción que aplica la decisión, conAND consumed_at IS NULL._reduce_and_completeloopea sobre lotes dereduce_batch_size(40,MEMORYMASTER_PROFILE_REDUCE_BATCH), releyendoactive_facts()entre lotes para que el lote N+1 fusione contra los hechos del N.Semántica, dicho explícito: las fusiones se deciden con visibilidad parcial, así que dos candidatos de lotes distintos pueden quedar como dos hechos donde una partición única los unía. Es el precio, y es preferible a no reducir nunca.
Evidencia en la base viva
El primer intento consumió 120/234 y falló en el cuarto lote — y los tres lotes previos quedaron. Antes ese mismo fallo tiraba todo, que es por qué diez noches seguidas terminaron igual. El reintento siguiente drenó los 114 restantes:
reducing, 10 díascompletedTecho del perfil: 800/40 → 1400/60
Va en el mismo commit porque el fix lo destapa: con 52 hechos el techo viejo cortaba 20 en silencio y la proyección quedaba clavada en 800/800 — justo cuando los hechos nuevos eran los buenos (
memorymaster and UNMS CRMsup=10, WISP, Task Scheduler, ARS) y los viejos los stale (Godot battle royale). Truncar por techo no avisa.Barra roja
Revirtiendo sólo el filtro de pendientes, la suite se cuelga hasta el timeout en vez de fallar un assert: el bucle relee los mismos 234 para siempre. Ese es exactamente el modo de falla que el filtro evita.
49/49 en tests de perfil, ruff limpio, inyección en SessionStart verificada en vivo (50 viñetas, marcador presente).