From 78879aef686ecec12b93268413a408362049e69d Mon Sep 17 00:00:00 2001 From: rafa Date: Mon, 3 Aug 2026 22:02:10 -0400 Subject: [PATCH] fea-homepage: fuera url_to_postid(), reventaba 36 cartas traducidas 36 URLs en 500 desde el cutover: las mismas 9 cartas de otras semanas en fr/it/en/pt. En espanol iban bien, y contra CDMON las mismas URLs daban 200, o sea que era regresion nuestra. La reescritura de enlaces internos al idioma activo llamaba a url_to_postid() una vez por enlace, y estas cartas traen unos 40. Cuando la URL no encaja en ninguna regla de reescritura, la WP_Query que monta esa funcion se queda sin clausula que la acote y se trae las 32.311 entradas CON su contenido, dos veces por peticion. 256 MB agotados en class-wpdb.php y 500. Se sustituye por fea_href_a_post_id(): ultimo segmento del path y una consulta con LIMIT 1, que no puede degenerar, cacheada por peticion. El ORDER BY reproduce a quien sirve WordPress esa misma URL, y no es cosmetico: hay 4 slugs compartidos por una pagina de primer nivel y una entrada, donde gana la pagina, y 58 compartidos por dos entradas -duplicados del import de Joomla- donde gana la de post_date mas reciente. Mi primer intento ordenaba por ID ASC y en esos 58 habria traducido el enlace equivocado. Verificado: 116/116 cartas traducidas en 200, 200 entradas al azar en 200, E2E 13/13, y el resolutor nuevo coincide con url_to_postid() en 461 de 461 slugs de los casos donde url_to_postid() no revienta. La consulta gorda ha desaparecido del performance_schema. Van dos centinelas a la suite E2E, una carta italiana y una francesa de las que fallaban. Nota de metodo para el futuro: esto no se reproduce con wp-cli, porque Polylang no instancia su frontend en CLI y el filtro sale antes de tiempo. Se cazo mirando events_statements_history_long en MySQL mientras se pedia la pagina por HTTP. Co-Authored-By: Claude Opus 5 --- .../500-cartas-traducidas-url-to-postid.md | 78 +++++++++++++++++++ docs/cutover/mu-plugins-manifest.txt | 2 +- tools/e2e/sites/www.json | 6 +- .../wp-content/mu-plugins/fea-homepage.php | 66 ++++++++++++++-- 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 docs/cutover/500-cartas-traducidas-url-to-postid.md mode change 100755 => 100644 wordpress/wp-content/mu-plugins/fea-homepage.php diff --git a/docs/cutover/500-cartas-traducidas-url-to-postid.md b/docs/cutover/500-cartas-traducidas-url-to-postid.md new file mode 100644 index 0000000..a61899c --- /dev/null +++ b/docs/cutover/500-cartas-traducidas-url-to-postid.md @@ -0,0 +1,78 @@ +# Los 500 de las cartas traducidas (`url_to_postid()`) + +## ✅ RESUELTO — 4-ago-2026, 02:00 UTC + +**36 URLs devolvían 500**: las mismas 9 «cartas de otras semanas» en los cuatro idiomas +traducidos. En español las 9 iban bien. + +``` +/fr/lumiere-et-phare/ /it/luce-e-faro/ /en/light-and-beacon/ /pt/luz-e-farol/ … ×9 +``` + +Era **una regresión del cutover, no algo heredado**: las mismas URLs contra CDMON respondían 200. + +## La causa + +`fea-homepage.php` reescribe los enlaces internos al idioma activo, y para eso llamaba a +**`url_to_postid()`** una vez por enlace. Estas cartas traen unos 40. + +`url_to_postid()` acaba construyendo una `WP_Query` a partir de las reglas de reescritura. Cuando +la URL no encaja en ninguna, la query se queda **sin cláusula que la acote** y sale esto: + +```sql +SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' ORDER BY post_date DESC +``` + +**Las 32.311 entradas con su contenido entero.** Dos veces por petición. Los 256 MB de PHP se +agotaban en `class-wpdb.php:2322` y Apache devolvía el 500. + +Encaja con el patrón observado: el filtro se salta el español (`if (!$lang || $lang === 'es') +return`), que es justo el idioma que no fallaba, y solo actúa en `is_singular()`. + +## El arreglo + +Se sustituye `url_to_postid()` por `fea_href_a_post_id()`: la estructura de enlaces es +`/%postname%/`, así que basta el último segmento del path y **una consulta con `LIMIT 1`**, que no +puede degenerar por rara que sea la URL. El resultado se cachea por petición. + +El orden del `ORDER BY` no es cosmético, reproduce a quién sirve WordPress esa misma URL: + +| caso | cuántos | criterio | +|---|---|---| +| página de primer nivel con el mismo slug que una entrada | 4 | gana la **página** (reglas verbosas de reescritura) | +| dos entradas con el mismo slug (duplicados del import de Joomla) | 58 | gana la de **`post_date` más reciente** | + +Ordenar por `ID ASC`, que fue el primer intento, devolvía la entrada vieja y habría traducido el +enlace equivocado en esos 58 casos. + +## Verificación + +| | | +|---|---| +| Las 116 cartas traducidas (29 × 4 idiomas) | **116/116 en 200** | +| Muestra de 200 entradas al azar (40 por idioma) | todas en 200 | +| `fea_href_a_post_id()` vs `url_to_postid()`, 61 slugs repetidos + 400 al azar | **461/461 idénticos** | +| La consulta gorda | desaparecida: la que más filas devuelve ahora son 640 de `wp_options` | +| Suite E2E | **13/13 en 200** | +| Errores PHP y 5xx tras el despliegue | 0 y 0 | + +Los enlaces se siguen reescribiendo, y **algunos más que antes**: `url_to_postid()` fallaba en +URLs que sí resuelven bien por slug. Comprobado contra CDMON que los nuevos pares son de verdad la +misma entrada (mismo grupo de traducción de Polylang): `como-bendecir-la-mesa` → +`comment-benir-la-table`, `felices-6` → `heureux`, `3-temario` → `programme`… + +## Centinelas + +`tools/e2e/sites/www.json` incorpora `carta-trad-it` y `carta-trad-fr`, dos de las que reventaban. +Si esto vuelve, lo dice la suite. + +## ⚠️ Para la próxima + +**`url_to_postid()` no es seguro con URLs arbitrarias en un sitio grande.** El comentario que ya +había en el fichero decía que en los listados agotaba memoria, y por eso se había acotado a +`is_singular()`. La cura se quedó corta: el problema no era el listado, era la función. + +Y una trampa de método: esto **no se reproduce con wp-cli**. Polylang no instancia su clase de +frontend en CLI, así que el filtro sale antes de tiempo y no pasa nada. Se cazó mirando +`performance_schema.events_statements_history_long` en MySQL mientras se pedía la página por HTTP, +que muestra la secuencia real de sentencias de la petición. diff --git a/docs/cutover/mu-plugins-manifest.txt b/docs/cutover/mu-plugins-manifest.txt index 3926a1b..e9f5e92 100644 --- a/docs/cutover/mu-plugins-manifest.txt +++ b/docs/cutover/mu-plugins-manifest.txt @@ -22,7 +22,7 @@ d3d8bfaf176f14cf80dd4b94e0c65682cdc19ff2cfd6226d14fb07bd4914448e fea-cookie-con eee49e2bc543f6bf03ce6083ee276a968532e38b915373317f15b49629c805e5 fea-disable-comments.php 721c52fdeab85e6bbc512b65792ca3a7d18c895d79d92c03714ff93136211a63 fea-gsc-verification.php 351734516531857c92cfec40c78faa5417866694e1c9e6976f1080ec29bbc77b fea-hide-bad-tag.php -7a15a508e2f3d9d94ed85141de96f07ce31a43298fae3cf09674fe9e7b031e35 fea-homepage.php +ccac15d46eae66f4782c7a859d22d6553c80be2324cd498309e6304e0a953455 fea-homepage.php 302da523f8c77ea2d2488fcd946d4e1207d49fbe39f26940115b7264aa268227 fea-legacy-redirect.php ecced1dc780edaabce659047860092428904b9a79688e908227f7ce086aa86c6 fea-menu-i18n.php cef207908964319efc0fb3a5929200f8fb9a4dbcce67e5b6bd2712b5cb83289b fea-pensamientos.php diff --git a/tools/e2e/sites/www.json b/tools/e2e/sites/www.json index 6bab198..baecb3f 100644 --- a/tools/e2e/sites/www.json +++ b/tools/e2e/sites/www.json @@ -5,7 +5,7 @@ "timeoutMs": 45000, "userAgent": "feadulta-e2e/0.1 (+local)", "hostResolverRules": "MAP www.feadulta.com 188.40.120.157, MAP feadulta.com 188.40.120.157", - "notes": "Produccion despues del cutover del 3-ago. Se resuelve a mano contra el Hetzner porque el WAF de Cloudflare devuelve 403 a nuestras IPs: por el dominio real no se puede verificar nada. Asi se comprueba el origen, que es lo que hemos cambiado.", + "notes": "Produccion despues del cutover del 3-ago. Se resuelve a mano contra el Hetzner porque el WAF de Cloudflare devuelve 403 a nuestras IPs: por el dominio real no se puede verificar nada. Asi se comprueba el origen, que es lo que hemos cambiado. Las dos carta-trad-* son cartas traducidas que dieron 500 por el url_to_postid() de fea-homepage: quedan aqui de centinela.", "urls": [ { "slug": "home-es", "path": "/" }, { "slug": "home-en", "path": "/en/" }, @@ -17,6 +17,8 @@ { "slug": "evangelio-dia", "path": "/evangelio-de-cada-dia/" }, { "slug": "autores", "path": "/autores-lista/" }, { "slug": "buscador", "path": "/?s=evangelio" }, - { "slug": "wp-login", "path": "/wp-login.php" } + { "slug": "wp-login", "path": "/wp-login.php" }, + { "slug": "carta-trad-it", "path": "/it/luce-e-faro/" }, + { "slug": "carta-trad-fr", "path": "/fr/pasteurs-et-petits-bergers/" } ] } diff --git a/wordpress/wp-content/mu-plugins/fea-homepage.php b/wordpress/wp-content/mu-plugins/fea-homepage.php old mode 100755 new mode 100644 index ef24ff3..941ac0d --- a/wordpress/wp-content/mu-plugins/fea-homepage.php +++ b/wordpress/wp-content/mu-plugins/fea-homepage.php @@ -2,7 +2,7 @@ /** * Plugin Name: Fe Adulta — Homepage * Description: Portada con selección editorial via ACF. - * Version: 1.4 + * Version: 1.5 */ // ── Flush rewrite rules una sola vez tras cambios de configuración ──────── @@ -1407,13 +1407,69 @@ add_shortcode('fea_noticia_centro', function() { }); // ── Reescribir links internos al idioma activo (Polylang) ───────────────── + +/** + * Resuelve un enlace interno a un ID de entrada, con coste acotado. + * + * NO usa url_to_postid(). Esa función termina construyendo una WP_Query a partir + * de las reglas de reescritura, y cuando la URL no encaja en ninguna (enlaces + * legacy de Joomla, rutas de idioma, .html sueltos) la query se queda sin cláusula + * que la acote y se trae las ~32.000 entradas CON su contenido: 256 MB de memoria + * y un 500. Reventaba 36 URLs —9 cartas × los 4 idiomas traducidos— desde el + * cutover del 3-ago. + * + * La estructura de enlaces es /%postname%/, así que basta con el último segmento + * del path. Una consulta con LIMIT 1 no puede degenerar por mucho que la URL sea + * rara, y el resultado se cachea por petición: las cartas traen ~40 enlaces y + * muchos se repiten. + */ +function fea_href_a_post_id(string $href): int { + static $cache = []; + if (isset($cache[$href])) return $cache[$href]; + + $path = (string) parse_url($href, PHP_URL_PATH); + $segmentos = array_values(array_filter(explode('/', $path), 'strlen')); + if (!$segmentos) return $cache[$href] = 0; + + $ultimo = (string) end($segmentos); + // Las URLs viejas de Joomla acaban en .html y nunca son un post_name. + if (substr($ultimo, -5) === '.html') return $cache[$href] = 0; + + // El slug puede venir percent-encoded en el href (introducci%C3%B3n-...). + $candidatos = array_values(array_unique(array_filter([ + $ultimo, + rawurldecode($ultimo), + sanitize_title(rawurldecode($ultimo)), + ], 'strlen'))); + if (!$candidatos) return $cache[$href] = 0; + + // El orden reproduce a quién sirve WordPress esa misma URL: + // - con la estructura /%postname%/ las páginas de primer nivel ganan al post + // homónimo (reglas verbosas de reescritura); hay 4 slugs así. + // - hay 58 slugs compartidos por dos entradas —duplicados del import de + // Joomla— y WP resuelve el name por post_date DESC. Ordenar por ID daría + // la vieja y traduciríamos el enlace equivocado. + global $wpdb; + $marcas = implode(',', array_fill(0, count($candidatos), '%s')); + $id = (int) $wpdb->get_var($wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} + WHERE post_name IN ($marcas) + AND post_status = 'publish' + AND post_type IN ('post','page') + ORDER BY (post_type = 'page' AND post_parent = 0) DESC, post_date DESC, ID DESC + LIMIT 1", + $candidatos + )); + + return $cache[$href] = $id; +} + add_filter('the_content', function($content) { if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $content; $lang = pll_current_language(); if (!$lang || $lang === 'es') return $content; - // Los archivos de cartas pueden contener cientos de enlaces. Resolver cada uno - // con url_to_postid() en un listado agota memoria; la reescritura solo aporta - // valor al mostrar el contenido completo de una entrada o página. + // La reescritura solo aporta valor al mostrar el contenido completo de una + // entrada o página, no en los listados. if (!is_singular()) return $content; return preg_replace_callback( @@ -1423,7 +1479,7 @@ add_filter('the_content', function($content) { $home = home_url(); if (strpos($href, $home) === false) return $m[0]; - $post_id = url_to_postid($href); + $post_id = fea_href_a_post_id($href); if (!$post_id) return $m[0]; $translated_id = pll_get_post($post_id, $lang);