PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/seo/class-sitemap-generator.php +596 -108 2.10.0 → 2.14.2 View file →
@@ -251,8 +251,33 @@
251 251 * @var int
252 252 */
253 253 private const TERM_WALK_CHUNK = 1000;
254 254
255 + /**
256 + * Exception code for an automatic rebuild stopped short of the memory limit.
257 + *
258 + * @since 2.10.1
259 + * @var int
260 + */
261 + private const MEMORY_ABORT_CODE = 4290;
262 +
263 + /**
264 + * Whether the chunked walks should stop before the memory limit. Only on
265 + * for automatic rebuilds, whose failure is recorded and retried.
266 + *
267 + * @since 2.10.1
268 + * @var bool
269 + */
270 + private bool $memory_guard = false;
271 +
272 + /**
273 + * Largest memory cost of one walked chunk in this rebuild, in bytes.
274 + *
275 + * @since 2.10.1
276 + * @var int
277 + */
278 + private int $walk_chunk_cost = 0;
279 +
255 280 private array $sitemap_types = [
256 281 'posts' => [
257 282 'name' => 'Posts',
258 283 'post_types' => ['post'],
@@ -279,25 +304,29 @@
279 304 ]
280 305 ];
281 306
282 307 /**
308 + * The generator the content-change listeners share, built on the first
309 + * change of a request.
310 + *
311 + * @since 2.10.1
312 + * @var self|null
313 + */
314 + private static ?self $listener = null;
315 +
316 + /**
283 317 * Constructor
284 318 *
285 319 * @since 1.0.0
320 + * @since 2.10.1 Registers no hooks, whatever `$register_hooks` says. The
321 + * content-change listeners are registered once, at bootstrap,
322 + * by register_content_listeners().
286 323 *
287 - * @param bool $register_hooks Optional. Whether to register the auto-generation
288 - * hooks. Pass false for a read-only instance built
289 - * solely to query settings — the hooks are bound to
290 - * `$this`, so a second hook-registering instance
291 - * would run `handle_content_change()` twice per save.
324 + * @param bool $register_hooks Unused since 2.10.1; kept so existing callers,
325 + * Pro's included, keep working.
292 326 */
293 327 public function __construct(bool $register_hooks = true) {
294 328 parent::__construct('sitemap');
295 -
296 - // Initialize auto-generation hooks
297 - if ($register_hooks) {
298 - $this->init_auto_generation_hooks();
299 - }
300 329 }
301 330
302 331 /**
303 332 * Filter the args of a sitemap post query.
@@ -341,34 +370,154 @@
341 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
342 371 }
343 372
344 373 /**
345 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
346 375 *
347 - * @since 1.0.0
376 + * Called once per request, at plugin bootstrap, next to the WP-Cron
377 + * listeners that run the rebuild these queue (both in
378 + * Plugin::register_sitemap_cron_listeners(), on plugins_loaded). The
379 + * constructor used to register them, so they existed only in a request
380 + * that happened to build a generator: the REST endpoint, the setup wizard,
381 + * an MCP ability. Block-editor saves go through REST and were heard. A
382 + * scheduled post published by WP-Cron, Quick Edit, the classic editor and
383 + * WP-CLI were not, so the post stayed out of the sitemap with nothing
384 + * pending to recover it (#824). Each instance also added its own set, so
385 + * one REST save ran the handlers once per generator built.
386 + *
387 + * The generator is built on the first change and shared for the rest of
388 + * the request, so a bulk edit does not construct one per post.
389 + *
390 + * @since 2.10.1
348 391 * @return void
349 392 */
350 - private function init_auto_generation_hooks(): void {
351 - // Content change hooks - use priority 20 to run after other plugins
352 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
353 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
354 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
355 - add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
393 + public static function register_content_listeners(): void {
394 + // Priority 20, to run after other plugins.
395 + add_action('save_post', static function (int $post_id, \WP_Post $post): void {
396 + self::listener()->handle_content_change($post_id, $post);
397 + }, 20, 2);
398 + add_action('delete_post', static function (int $post_id): void {
399 + self::listener()->handle_content_deletion($post_id);
400 + }, 20);
401 + add_action('wp_trash_post', static function (int $post_id): void {
402 + self::listener()->handle_content_deletion($post_id);
403 + }, 20);
404 + add_action('untrash_post', static function (int $post_id): void {
405 + self::listener()->handle_content_change_by_id($post_id);
406 + }, 20);
356 407
357 - // Taxonomy change hooks
358 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
359 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
360 - add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
408 + foreach (['created_term', 'edited_term', 'delete_term'] as $hook) {
409 + add_action($hook, static function (int $term_id, int $tt_id, string $taxonomy): void {
410 + self::listener()->handle_taxonomy_change($term_id, $tt_id, $taxonomy);
411 + }, 20, 3);
412 + }
361 413
362 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
363 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
364 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
365 - // rather than here. A cron run never builds this class via the REST
366 - // endpoint (no rest_api_init), so registering them in the constructor
367 - // would leave the scheduled events with no listener at cron time.
414 + // The sitemap leaves out what the robots tag noindexes and what
415 + // ThinkRank redirects (#911), so a change to either decides what the
416 + // published files should list. Neither is a post or term save, and
417 + // without these the files kept the old answer until unrelated content
418 + // changed.
419 + foreach (self::ROBOTS_SETTINGS_OPTIONS as $option) {
420 + add_action('update_option_' . $option, static function ($old_value, $value): void {
421 + self::handle_robots_settings_change($old_value, $value);
422 + }, 20, 2);
423 + add_action('add_option_' . $option, static function ($name, $value): void {
424 + self::handle_robots_settings_change([], $value);
425 + }, 20, 2);
426 + }
427 +
428 + add_action('thinkrank_object_redirect_saved', static function (): void {
429 + self::listener()->schedule_regeneration();
430 + }, 20, 0);
368 431 }
369 432
370 433 /**
434 + * Options holding robots directives the sitemap's item filter reads.
435 + *
436 + * @since 2.15.0
437 + * @var string[]
438 + */
439 + private const ROBOTS_SETTINGS_OPTIONS = [
440 + 'thinkrank_global_seo_settings',
441 + 'thinkrank_global_robot_meta_settings',
442 + ];
443 +
444 + /**
445 + * Queue a rebuild when a saved robots setting changes a noindex decision.
446 + *
447 + * Both options carry far more than robots directives (titles, schema,
448 + * feature switches), so only a change to a noindex outcome rebuilds.
449 + *
450 + * @since 2.15.0
451 + *
452 + * @param mixed $old_value Previous option value.
453 + * @param mixed $value New option value.
454 + * @return void
455 + */
456 + public static function handle_robots_settings_change($old_value, $value): void {
457 + if (self::noindex_fingerprint($old_value) === self::noindex_fingerprint($value)) {
458 + return;
459 + }
460 +
461 + // The matrix memoises the option for the request, and a rebuild that
462 + // runs in this request must read the value just saved.
463 + Content_Type_Settings::flush_cache();
464 +
465 + self::listener()->schedule_regeneration();
466 + }
467 +
468 + /**
469 + * The noindex decisions a robots option makes, keyed by what they apply to.
470 + *
471 + * Reads both shapes: the flat site-wide directives, and the per-entity
472 + * rows, where a row's directives apply only while its robots switch is on.
473 + * A row that is on but stores no `noindex` key changes nothing, matching
474 + * the array_merge() the robots tag does.
475 + *
476 + * @since 2.15.0
477 + *
478 + * @param mixed $value Option value.
479 + * @return array<string, bool|null>
480 + */
481 + private static function noindex_fingerprint($value): array {
482 + if (!is_array($value)) {
483 + return [];
484 + }
485 +
486 + $decisions = [];
487 +
488 + if (array_key_exists('noindex', $value) && !is_array($value['noindex'])) {
489 + $decisions['*'] = !empty($value['noindex']);
490 + }
491 +
492 + foreach ($value as $key => $row) {
493 + if (!is_array($row)) {
494 + continue;
495 + }
496 +
497 + $robots = $row['robots_meta'] ?? null;
498 +
499 + $decisions[(string) $key] = !empty($row['robots_meta_enabled']) && is_array($robots) && array_key_exists('noindex', $robots)
500 + ? !empty($robots['noindex'])
501 + : null;
502 + }
503 +
504 + ksort($decisions);
505 +
506 + return $decisions;
507 + }
508 +
509 + /**
510 + * The generator the content-change listeners share.
511 + *
512 + * @since 2.10.1
513 + * @return self
514 + */
515 + private static function listener(): self {
516 + return self::$listener ??= new self(false);
517 + }
518 +
519 + /**
371 520 * Generate XML sitemap
372 521 *
373 522 * @since 1.0.0
374 523 *
@@ -751,9 +900,12 @@
751 900
752 901 // Add image entries if provided
753 902 foreach ($images as $image) {
754 903 $xml .= " <image:image>\n";
755 - $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
904 + // The sitemap protocol wants an escaped URL, and WordPress hands back
905 + // attachment URLs with non-ASCII filenames unencoded (esc_url() does
906 + // not encode them either), so write the percent-encoded form (#924).
907 + $xml .= " <image:loc>" . esc_url(\ThinkRank\Core\Url_Validator::to_ascii(Url_Scheme::apply((string) $image['url']))) . "</image:loc>\n";
756 908
757 909 if (!empty($image['title'])) {
758 910 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
759 911 }
@@ -874,8 +1026,11 @@
874 1026 // well-bounded routine with no ceiling (#402).
875 1027 $total = count($all_ids);
876 1028
877 1029 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
1030 + $this->assert_memory_headroom();
1031 + $chunk_start = memory_get_usage(true);
1032 +
878 1033 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
879 1034
880 1035 $posts = get_posts($this->filter_query_args([
881 1036 'post_type' => $post_types,
@@ -884,8 +1039,12 @@
884 1039 'post__in' => $chunk,
885 1040 'orderby' => 'post__in', // preserve the resolved order
886 1041 ]));
887 1042
1043 + // get_featured_image() hydrates each featured image under the
1044 + // attachment's own ID, which the chunk's post IDs do not reach.
1045 + $attachment_ids = [];
1046 +
888 1047 foreach ($posts as $post) {
889 1048 if ($this->should_include_in_sitemap($post, $settings)) {
890 1049 /**
891 1050 * Filter a sitemap entry's permalink.
@@ -906,14 +1065,25 @@
906 1065 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
907 1066 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
908 1067 $images = $this->extract_post_images($post, $settings);
909 1068
1069 + $thumbnail_id = (int) get_post_thumbnail_id($post);
1070 + if ($thumbnail_id > 0) {
1071 + $attachment_ids[] = $thumbnail_id;
1072 + }
1073 +
910 1074 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
911 1075 }
912 1076 }
913 1077
914 - // Free the hydrated chunk before loading the next one.
1078 + // Free the hydrated chunk before loading the next one — including
1079 + // the copies get_posts() left in the runtime object cache.
915 1080 unset($posts);
1081 + $this->release_walk_memory(
1082 + $chunk_start,
1083 + $this->chunk_post_cache_groups($post_types),
1084 + array_merge($chunk, $attachment_ids)
1085 + );
916 1086 }
917 1087 }
918 1088
919 1089 /**
@@ -973,8 +1143,11 @@
973 1143 // Same moving window as the post walk above, for the same reason.
974 1144 $total = count($all_ids);
975 1145
976 1146 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1147 + $this->assert_memory_headroom();
1148 + $chunk_start = memory_get_usage(true);
1149 +
977 1150 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
978 1151
979 1152 $terms = get_terms($this->filter_term_query_args([
980 1153 'taxonomy' => $taxonomy,
@@ -987,12 +1160,12 @@
987 1160 continue;
988 1161 }
989 1162
990 1163 foreach ($terms as $term) {
991 - // A term the user marked noindex must not be advertised in the
992 - // sitemap: the robots tag now honours term meta, so listing it
993 - // here would have the sitemap contradict the page's own tag.
994 - if ($this->term_is_noindexed((int) $term->term_id)) {
1164 + // A term whose archive says noindex (its own override or its
1165 + // taxonomy's), or that redirects, must not be advertised: the
1166 + // sitemap would contradict the page's own signal (#911).
1167 + if (!Indexability::is_indexable_term($term)) {
995 1168 continue;
996 1169 }
997 1170
998 1171 $url = get_term_link($term);
@@ -1003,8 +1176,9 @@
1003 1176 }
1004 1177 }
1005 1178
1006 1179 unset($terms);
1180 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
1007 1181 }
1008 1182 }
1009 1183
1010 1184 /**
@@ -1179,9 +1353,9 @@
1179 1353 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
1180 1354 $image_url = $src_match[1];
1181 1355
1182 1356 // Skip if not a valid URL or external image
1183 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
1357 + if (!\ThinkRank\Core\Url_Validator::is_valid($image_url)) {
1184 1358 continue;
1185 1359 }
1186 1360
1187 1361 // Extract title and alt attributes
@@ -1296,14 +1470,15 @@
1296 1470 if (is_wp_error($all_terms)) {
1297 1471 return [];
1298 1472 }
1299 1473
1300 - // Drop terms the user marked noindex. This path feeds the single general
1301 - // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1302 - // so both need the filter or the two disagree about the same term.
1474 + // Drop terms that are not indexable destinations. This path feeds the
1475 + // single general sitemap while collect_taxonomy_entries_iter() feeds
1476 + // the segmented ones, so both need the filter or the two disagree about
1477 + // the same term.
1303 1478 $all_terms = array_values(array_filter(
1304 1479 $all_terms,
1305 - fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1480 + static fn($term) => $term instanceof \WP_Term && Indexability::is_indexable_term($term)
1306 1481 ));
1307 1482
1308 1483 // Group terms by taxonomy
1309 1484 return $this->group_terms_by_taxonomy($all_terms);
@@ -1485,17 +1660,16 @@
1485 1660 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1486 1661 return false;
1487 1662 }
1488 1663
1489 - // Check if post overrides robots and sets noindex.
1490 - if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1491 - $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1492 - if (is_string($raw) && $raw !== '') {
1493 - $robots = json_decode($raw, true);
1494 - if (is_array($robots) && !empty($robots['noindex'])) {
1495 - return false;
1496 - }
1497 - }
1664 + // A URL whose page says noindex, or that ThinkRank redirects, is not a
1665 + // destination. Only the per-post noindex used to be read here, so a
1666 + // post type set to No-index still had every item listed, and redirected
1667 + // posts were submitted as "Page with redirect" (#911). The password
1668 + // check stays with the setting above: listing protected posts is a
1669 + // choice this sitemap has always offered.
1670 + if (Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post)) {
1671 + return false;
1498 1672 }
1499 1673
1500 1674 return true;
1501 1675 }
@@ -1534,35 +1708,8 @@
1534 1708 return $ids;
1535 1709 }
1536 1710
1537 1711 /**
1538 - * Whether a term carries an explicit noindex override.
1539 - *
1540 - * Mirrors the post-side check in should_include_post(); terms store the same
1541 - * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1542 - * by the update-term-seo ability and by the SEO importer.
1543 - *
1544 - * @since 1.31.0
1545 - *
1546 - * @param int $term_id Term to test.
1547 - * @return bool True when the term is marked noindex.
1548 - */
1549 - private function term_is_noindexed(int $term_id): bool {
1550 - if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1551 - return false;
1552 - }
1553 -
1554 - $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1555 - if (!is_string($raw) || $raw === '') {
1556 - return false;
1557 - }
1558 -
1559 - $robots = json_decode($raw, true);
1560 -
1561 - return is_array($robots) && !empty($robots['noindex']);
1562 - }
1563 -
1564 - /**
1565 1712 * Count total URLs in sitemap
1566 1713 *
1567 1714 * @since 1.0.0
1568 1715 *
@@ -1854,11 +2001,13 @@
1854 2001 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1855 2002 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1856 2003 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1857 2004
2005 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
2006 + // `memory_limit`) survives an edit made while it is outstanding.
1858 2007 update_option(
1859 2008 self::REGENERATION_PENDING_OPTION,
1860 - [
2009 + array_merge($pending, [
1861 2010 'since' => $since,
1862 2011 'source' => $source,
1863 2012 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1864 2013 'next_attempt' => !empty($pending['next_attempt'])
@@ -1866,9 +2015,9 @@
1866 2015 : time() + self::REGENERATION_TAKEOVER_GRACE,
1867 2016 // Bumped on every change so a rebuild can tell whether the edit
1868 2017 // it started for is still the newest one outstanding.
1869 2018 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1870 - ],
2019 + ]),
1871 2020 true
1872 2021 );
1873 2022 }
1874 2023
@@ -1914,8 +2063,18 @@
1914 2063 $revision !== null
1915 2064 && is_array($pending)
1916 2065 && (int) ($pending['revision'] ?? 0) !== $revision
1917 2066 ) {
2067 + // This attempt succeeded, so drop what it claimed: the newer change
2068 + // waits the grace a fresh edit gets, not a failure backoff it never
2069 + // earned. Not zero: that edit queued its own debounced event, and
2070 + // a marker due at once had the next admin request rebuild in its
2071 + // shutdown and the event rebuild again seconds later.
2072 + unset($pending['started'], $pending['memory_limit']);
2073 + $pending['attempts'] = 0;
2074 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2075 +
2076 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
1918 2077 return;
1919 2078 }
1920 2079
1921 2080 delete_option(self::REGENERATION_PENDING_OPTION);
@@ -1921,8 +2080,69 @@
1921 2080 delete_option(self::REGENERATION_PENDING_OPTION);
1922 2081 }
1923 2082
1924 2083 /**
2084 + * Record the attempt that is about to run before it runs.
2085 + *
2086 + * A PHP fatal — the memory limit or max_execution_time — is not a
2087 + * Throwable, so no catch or finally around the generation runs when the
2088 + * process dies, and a failure recorded afterwards was never recorded at
2089 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2090 + * started the same doomed rebuild again (a fatal every few minutes for as
2091 + * long as an admin was logged in). Claiming the attempt up front makes the
2092 + * backoff hold even when nothing after this line gets to run, and leaves a
2093 + * `started` stamp the next attempt can recognise as an interrupted one.
2094 + *
2095 + * @since 2.10.1
2096 + * @return void
2097 + */
2098 + private function claim_regeneration_attempt(): void {
2099 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2100 +
2101 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2102 + // there is no marker to retry from, so nothing to claim.
2103 + if (!is_array($pending) || empty($pending['since'])) {
2104 + return;
2105 + }
2106 +
2107 + if (!empty($pending['started'])) {
2108 + // The previous attempt claimed itself and never reported back.
2109 + update_option(
2110 + self::REGENERATION_ERROR_OPTION,
2111 + [
2112 + 'message' => __('The previous automatic sitemap rebuild stopped before it finished, most likely because PHP ran out of memory or time. It is retried with a growing delay. If this keeps happening, raise the PHP memory_limit or max_execution_time, or run WP-Cron from a system cron.', 'thinkrank'),
2113 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2114 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2115 + 'time' => (int) $pending['started'],
2116 + ],
2117 + false
2118 + );
2119 + }
2120 +
2121 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2122 +
2123 + $pending['attempts'] = $attempts;
2124 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2125 + $pending['started'] = time();
2126 +
2127 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2128 + }
2129 +
2130 + /**
2131 + * Delay before the next takeover after the given number of attempts.
2132 + *
2133 + * @since 2.10.1
2134 + * @param int $attempts Attempts made so far (1 or more).
2135 + * @return int Seconds.
2136 + */
2137 + private function regeneration_backoff(int $attempts): int {
2138 + return (int) min(
2139 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2140 + self::REGENERATION_MAX_BACKOFF
2141 + );
2142 + }
2143 +
2144 + /**
1925 2145 * Record a failed regeneration instead of discarding it.
1926 2146 *
1927 2147 * Keeps the pending marker in place so the rebuild is retried, but backs the
1928 2148 * next attempt off exponentially (capped) so a persistently failing
@@ -1928,22 +2148,30 @@
1928 2148 * next attempt off exponentially (capped) so a persistently failing
1929 2149 * generation cannot run on every admin request.
1930 2150 *
1931 2151 * @since 2.2.1
1932 - * @param string $message Failure detail.
1933 - * @param string $source Either 'content' or 'settings'.
2152 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2153 + * does not count an attempt claim_regeneration_attempt()
2154 + * already counted.
2155 + * @param string $message Failure detail.
2156 + * @param string $source Either 'content' or 'settings'.
2157 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2158 + * short of, when that was the failure.
1934 2159 * @return void
1935 2160 */
1936 - private function record_regeneration_failure(string $message, string $source): void {
2161 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
1937 2162 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1938 2163 $pending = is_array($pending) ? $pending : [];
1939 - $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2164 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
1940 2165
1941 - $backoff = min(
1942 - self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1943 - self::REGENERATION_MAX_BACKOFF
1944 - );
2166 + // An attempt that claimed itself up front has already been counted.
2167 + if (empty($pending['started'])) {
2168 + $attempts++;
2169 + }
2170 + $attempts = max($attempts, 1);
1945 2171
2172 + $backoff = $this->regeneration_backoff($attempts);
2173 +
1946 2174 // Same precedence mark_regeneration_pending() enforces: a settings
1947 2175 // rebuild outranks a content one and must not be downgraded by a failed
1948 2176 // attempt. Overwriting it routed the retry back through the content
1949 2177 // path, where should_auto_generate() can be false and the completion
@@ -1952,20 +2180,24 @@
1952 2180 // whichever attempt actually failed.
1953 2181 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1954 2182 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1955 2183
1956 - update_option(
1957 - self::REGENERATION_PENDING_OPTION,
1958 - [
1959 - 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1960 - 'source' => $pending_source,
1961 - 'attempts' => $attempts,
1962 - 'next_attempt' => time() + $backoff,
1963 - 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1964 - ],
1965 - true
1966 - );
2184 + $marker = [
2185 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2186 + 'source' => $pending_source,
2187 + 'attempts' => $attempts,
2188 + 'next_attempt' => time() + $backoff,
2189 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2190 + ];
1967 2191
2192 + // Remembered so has_memory_for_retry() can keep requests with no more
2193 + // memory than this from repeating the same attempt.
2194 + if ($memory_limit !== null && $memory_limit > 0) {
2195 + $marker['memory_limit'] = $memory_limit;
2196 + }
2197 +
2198 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2199 +
1968 2200 update_option(
1969 2201 self::REGENERATION_ERROR_OPTION,
1970 2202 [
1971 2203 'message' => $message,
@@ -2020,16 +2252,34 @@
2020 2252 if (!self::has_overdue_regeneration()) {
2021 2253 return;
2022 2254 }
2023 2255
2024 - // Cron is running: it is about to do exactly this work.
2256 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2257 + $pending = is_array($pending) ? $pending : [];
2258 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2259 +
2260 + // Cron is running and its own event for this rebuild is still queued
2261 + // and due: it is about to do exactly this work. Only then — an event
2262 + // that has already been consumed (e.g. it fired while another process
2263 + // held the generation lock) is never re-queued, and returning here
2264 + // unconditionally left the rebuild to requests that could not finish it.
2025 2265 if (wp_doing_cron()) {
2266 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2267 + $next = wp_next_scheduled($hook);
2268 +
2269 + if ($next !== false && $next <= time()) {
2270 + return;
2271 + }
2272 + }
2273 +
2274 + // The last attempt had to stop short of this process's memory limit.
2275 + // Retrying at the same (or a lower) limit only repeats that, so leave
2276 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2277 + // host with a higher limit — instead of burning it on every request.
2278 + if (!$this->has_memory_for_retry($pending)) {
2026 2279 return;
2027 2280 }
2028 2281
2029 - $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2030 - $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
2031 -
2032 2282 if ($source === 'settings') {
2033 2283 $this->regenerate_sitemap_from_settings();
2034 2284 return;
2035 2285 }
@@ -2063,8 +2313,228 @@
2063 2313 delete_transient(self::GENERATION_LOCK_TRANSIENT);
2064 2314 }
2065 2315
2066 2316 /**
2317 + * This process's PHP memory limit in bytes.
2318 + *
2319 + * @since 2.10.1
2320 + * @return int Bytes, or -1 when unlimited (or unreadable).
2321 + */
2322 + private function current_memory_limit(): int {
2323 + $limit = (string) ini_get('memory_limit');
2324 +
2325 + if ($limit === '' || $limit === '-1') {
2326 + return -1;
2327 + }
2328 +
2329 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2330 +
2331 + return $bytes > 0 ? $bytes : -1;
2332 + }
2333 +
2334 + /**
2335 + * May this process retry a rebuild that last stopped at the memory limit?
2336 + *
2337 + * Raises the limit the way wp-admin does first, so a request that can get
2338 + * more room than the failed attempt had is still allowed to try.
2339 + *
2340 + * @since 2.10.1
2341 + * @param array $pending The pending marker.
2342 + * @return bool True when there is no recorded memory failure, or this
2343 + * process has more memory than the attempt that failed.
2344 + */
2345 + private function has_memory_for_retry(array $pending): bool {
2346 + if (empty($pending['memory_limit'])) {
2347 + return true;
2348 + }
2349 +
2350 + wp_raise_memory_limit('admin');
2351 +
2352 + $limit = $this->current_memory_limit();
2353 +
2354 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2355 + }
2356 +
2357 + /**
2358 + * Release what one walked chunk left behind.
2359 + *
2360 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2361 + * object and its meta in the in-process object cache, which nothing
2362 + * empties until the request ends. Unsetting the chunk therefore freed
2363 + * nothing, and the walk grew with the size of the site instead of the size
2364 + * of a chunk — about 1.3 GB on a 45k-post site.
2365 + *
2366 + * A persistent object cache that supports it drops only its in-process
2367 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2368 + * default cache has no shared store, and flushing it would empty every
2369 + * group for the rest of the request (options, the queried object, other
2370 + * plugins' data), so there only the chunk's own entries are deleted. A
2371 + * persistent cache without `flush_runtime` is left alone: deleting from it
2372 + * would evict the objects for every other request too.
2373 + *
2374 + * @since 2.10.1
2375 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2376 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2377 + * @param int[] $ids The chunk's object IDs, plus any objects it
2378 + * hydrated under their own (featured images).
2379 + * @return void
2380 + * @throws \Error See assert_memory_headroom().
2381 + */
2382 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2383 + // What one chunk costs before it is released: the margin the next one
2384 + // needs. Measured in the same real allocated size assert_memory_headroom()
2385 + // compares against the limit, so the two are the same unit.
2386 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2387 +
2388 + if (wp_using_ext_object_cache()) {
2389 + if (
2390 + function_exists('wp_cache_supports')
2391 + && wp_cache_supports('flush_runtime')
2392 + && function_exists('wp_cache_flush_runtime')
2393 + ) {
2394 + wp_cache_flush_runtime();
2395 + }
2396 + } elseif (!empty($ids)) {
2397 + foreach ($groups as $group) {
2398 + wp_cache_delete_multiple($ids, $group);
2399 + }
2400 + }
2401 +
2402 + $this->assert_memory_headroom();
2403 + }
2404 +
2405 + /**
2406 + * Cache groups get_posts() fills per post for the given post types.
2407 + *
2408 + * The post, its meta, and one relationships group per taxonomy the post
2409 + * type uses (update_object_term_cache()). Term objects themselves are
2410 + * bounded by the number of terms, not posts, so they are left cached.
2411 + *
2412 + * @since 2.10.1
2413 + * @param string[] $post_types Post types being walked.
2414 + * @return string[] Cache groups keyed by post ID.
2415 + */
2416 + private function chunk_post_cache_groups(array $post_types): array {
2417 + $groups = ['posts', 'post_meta'];
2418 +
2419 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2420 + $groups[] = $taxonomy . '_relationships';
2421 + }
2422 +
2423 + return array_values(array_unique($groups));
2424 + }
2425 +
2426 + /**
2427 + * Stop an automatic rebuild before the memory limit rather than at it.
2428 + *
2429 + * A PHP memory fatal skips every catch and finally, so the lock, the
2430 + * failure record and the backoff are all lost with it, while stopping here
2431 + * is an ordinary, fully recorded failure. Checked before each chunk is
2432 + * hydrated, against a margin of at least the largest chunk seen so far.
2433 + *
2434 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2435 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2436 + * swallow it and carry on — writing an index without the aborted segments
2437 + * and then pruning their files as orphans. Only the automatic rebuild's
2438 + * catch (\Throwable) is meant to see it.
2439 + *
2440 + * @since 2.10.1
2441 + * @return void
2442 + * @throws \Error When the automatic rebuild is close to the memory limit.
2443 + */
2444 + private function assert_memory_headroom(): void {
2445 + if (!$this->memory_guard) {
2446 + return;
2447 + }
2448 +
2449 + $limit = $this->current_memory_limit();
2450 + if ($limit === -1) {
2451 + return;
2452 + }
2453 +
2454 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2455 + // and a half of the costliest chunk if that is more — and never more
2456 + // than half the limit either way. Without that outer cap a single
2457 + // anomalously expensive chunk (500 posts of serialised page-builder or
2458 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2459 + // limit itself, so every later check aborts at any usage at all, the
2460 + // failure records this process's limit, and has_memory_for_retry()
2461 + // then refuses every process that has the same limit. A site that
2462 + // never actually ran out of memory would stop rebuilding until WP-CLI
2463 + // or a system cron happened to run.
2464 + $headroom = (int) min(
2465 + max(
2466 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2467 + $this->walk_chunk_cost * 1.5
2468 + ),
2469 + $limit * 0.5
2470 + );
2471 +
2472 + // The real allocated size, which is what PHP enforces memory_limit
2473 + // against; memory_get_usage(false) reports only what is handed out of
2474 + // those allocations and so understates the margin by the allocator's
2475 + // slack.
2476 + $usage = memory_get_usage(true);
2477 +
2478 + if ($usage > $limit - $headroom) {
2479 + throw new \Error(
2480 + sprintf(
2481 + /* translators: 1: memory in use, 2: PHP memory limit. */
2482 + esc_html__('The sitemap rebuild was stopped at %1$s of the %2$s PHP memory limit, before PHP would have run out of memory. It will be retried by a process with more memory (WP-CLI or a system cron). To let it finish in the admin, raise the PHP memory_limit.', 'thinkrank'),
2483 + esc_html((string) size_format($usage)),
2484 + esc_html((string) size_format($limit))
2485 + ),
2486 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an integer class constant, not output.
2487 + self::MEMORY_ABORT_CODE
2488 + );
2489 + }
2490 + }
2491 +
2492 + /**
2493 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2494 + *
2495 + * @since 2.10.1
2496 + * @param array $settings Sitemap settings.
2497 + * @return bool Whatever generate_and_save() returned.
2498 + * @throws \Throwable Whatever generation throws, after the memory guard is
2499 + * switched back off.
2500 + */
2501 + private function generate_for_regeneration(array $settings): bool {
2502 + // Same headroom wp-admin gives itself; a no-op when the limit is
2503 + // already higher or unlimited.
2504 + wp_raise_memory_limit('admin');
2505 +
2506 + $this->claim_regeneration_attempt();
2507 + $this->memory_guard = true;
2508 + $this->walk_chunk_cost = 0;
2509 +
2510 + try {
2511 + return $this->generate_and_save($settings);
2512 + } finally {
2513 + $this->memory_guard = false;
2514 + }
2515 + }
2516 +
2517 + /**
2518 + * Record a failure thrown by an automatic rebuild.
2519 + *
2520 + * @since 2.10.1
2521 + * @param \Throwable $e What was thrown.
2522 + * @param string $source Either 'content' or 'settings'.
2523 + * @return void
2524 + */
2525 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2526 + $memory_limit = null;
2527 +
2528 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2529 + $memory_limit = $this->current_memory_limit();
2530 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2531 + }
2532 +
2533 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2534 + }
2535 +
2536 + /**
2067 2537 * Report how automatic regeneration is faring, for the admin UI.
2068 2538 *
2069 2539 * The feature used to fail invisibly: `last_generated` simply stopped
2070 2540 * advancing and nothing drew attention to it (#629).
@@ -2214,9 +2684,9 @@
2214 2684 // so the switch has not taken effect (#764).
2215 2685 return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2216 2686 }
2217 2687
2218 - if ($this->generate_and_save($settings)) {
2688 + if ($this->generate_for_regeneration($settings)) {
2219 2689 $this->mark_regeneration_complete($revision);
2220 2690
2221 2691 return true;
2222 2692 }
@@ -2227,9 +2697,9 @@
2227 2697 );
2228 2698
2229 2699 return false;
2230 2700 } catch (\Throwable $e) {
2231 - $this->record_regeneration_failure($e->getMessage(), 'settings');
2701 + $this->record_thrown_regeneration_failure($e, 'settings');
2232 2702
2233 2703 return false;
2234 2704 } finally {
2235 2705 $this->release_generation_lock();
@@ -2363,9 +2833,9 @@
2363 2833 $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2364 2834 return;
2365 2835 }
2366 2836
2367 - if ($this->generate_and_save($settings)) {
2837 + if ($this->generate_for_regeneration($settings)) {
2368 2838 $this->mark_regeneration_complete($revision);
2369 2839 } else {
2370 2840 // Previously this returned quietly and last_generated simply
2371 2841 // stopped advancing, leaving the site owner with no way to learn
@@ -2375,9 +2845,9 @@
2375 2845 'content'
2376 2846 );
2377 2847 }
2378 2848 } catch (\Throwable $e) {
2379 - $this->record_regeneration_failure($e->getMessage(), 'content');
2849 + $this->record_thrown_regeneration_failure($e, 'content');
2380 2850 } finally {
2381 2851 $this->release_generation_lock();
2382 2852 }
2383 2853 }
@@ -3415,12 +3885,30 @@
3415 3885 $buffer = [];
3416 3886 }
3417 3887 }
3418 3888
3419 - // Flush the trailing partial page, or a single empty page when the
3420 - // type had no entries at all (parity with the previous behavior of
3421 - // always writing at least one page per configured child).
3422 - if (!empty($buffer) || $page === 0) {
3889 + // Flush the trailing partial page.
3890 + //
3891 + // A type that produced nothing writes no page at all in index
3892 + // mode (#836). It used to write one empty urlset and list it in
3893 + // the index, so a crawler was asked to fetch a file that
3894 + // answers with no URLs — Search Console reports an empty
3895 + // sitemap referenced from an index as a warning, and the fetch
3896 + // is wasted on every pass. On this site three of eleven
3897 + // children were empty: a post type with nothing published and
3898 + // two taxonomies with no terms.
3899 + //
3900 + // Single-file mode still writes its one page even when empty,
3901 + // because that file IS the site's /sitemap.xml and a 404 there
3902 + // is worse than an empty urlset. Nothing lists it, so it costs
3903 + // no crawl budget.
3904 + //
3905 + // Skipping the write also keeps the filename out of
3906 + // $results['sitemaps_generated'], which is what
3907 + // prune_orphaned_segments() treats as "written this run" — so
3908 + // a type that empties after previously publishing has its
3909 + // stale file deleted rather than left serving.
3910 + if (!empty($buffer) || ($page === 0 && $index_config === null)) {
3423 3911 $page++;
3424 3912 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
3425 3913 }
3426 3914