PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/seo/class-sitemap-generator.php +564 -81 2.9.0 → 2.11.0 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,31 +370,57 @@
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 + }
413 + }
361 414
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.
415 + /**
416 + * The generator the content-change listeners share.
417 + *
418 + * @since 2.10.1
419 + * @return self
420 + */
421 + private static function listener(): self {
422 + return self::$listener ??= new self(false);
368 423 }
369 424
370 425 /**
371 426 * Generate XML sitemap
@@ -874,8 +929,11 @@
874 929 // well-bounded routine with no ceiling (#402).
875 930 $total = count($all_ids);
876 931
877 932 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
933 + $this->assert_memory_headroom();
934 + $chunk_start = memory_get_usage(true);
935 +
878 936 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
879 937
880 938 $posts = get_posts($this->filter_query_args([
881 939 'post_type' => $post_types,
@@ -884,8 +942,12 @@
884 942 'post__in' => $chunk,
885 943 'orderby' => 'post__in', // preserve the resolved order
886 944 ]));
887 945
946 + // get_featured_image() hydrates each featured image under the
947 + // attachment's own ID, which the chunk's post IDs do not reach.
948 + $attachment_ids = [];
949 +
888 950 foreach ($posts as $post) {
889 951 if ($this->should_include_in_sitemap($post, $settings)) {
890 952 /**
891 953 * Filter a sitemap entry's permalink.
@@ -906,14 +968,25 @@
906 968 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
907 969 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
908 970 $images = $this->extract_post_images($post, $settings);
909 971
972 + $thumbnail_id = (int) get_post_thumbnail_id($post);
973 + if ($thumbnail_id > 0) {
974 + $attachment_ids[] = $thumbnail_id;
975 + }
976 +
910 977 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
911 978 }
912 979 }
913 980
914 - // Free the hydrated chunk before loading the next one.
981 + // Free the hydrated chunk before loading the next one — including
982 + // the copies get_posts() left in the runtime object cache.
915 983 unset($posts);
984 + $this->release_walk_memory(
985 + $chunk_start,
986 + $this->chunk_post_cache_groups($post_types),
987 + array_merge($chunk, $attachment_ids)
988 + );
916 989 }
917 990 }
918 991
919 992 /**
@@ -973,8 +1046,11 @@
973 1046 // Same moving window as the post walk above, for the same reason.
974 1047 $total = count($all_ids);
975 1048
976 1049 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1050 + $this->assert_memory_headroom();
1051 + $chunk_start = memory_get_usage(true);
1052 +
977 1053 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
978 1054
979 1055 $terms = get_terms($this->filter_term_query_args([
980 1056 'taxonomy' => $taxonomy,
@@ -1003,8 +1079,9 @@
1003 1079 }
1004 1080 }
1005 1081
1006 1082 unset($terms);
1083 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
1007 1084 }
1008 1085 }
1009 1086
1010 1087 /**
@@ -1854,11 +1931,13 @@
1854 1931 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1855 1932 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1856 1933 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1857 1934
1935 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
1936 + // `memory_limit`) survives an edit made while it is outstanding.
1858 1937 update_option(
1859 1938 self::REGENERATION_PENDING_OPTION,
1860 - [
1939 + array_merge($pending, [
1861 1940 'since' => $since,
1862 1941 'source' => $source,
1863 1942 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1864 1943 'next_attempt' => !empty($pending['next_attempt'])
@@ -1866,9 +1945,9 @@
1866 1945 : time() + self::REGENERATION_TAKEOVER_GRACE,
1867 1946 // Bumped on every change so a rebuild can tell whether the edit
1868 1947 // it started for is still the newest one outstanding.
1869 1948 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1870 - ],
1949 + ]),
1871 1950 true
1872 1951 );
1873 1952 }
1874 1953
@@ -1914,8 +1993,18 @@
1914 1993 $revision !== null
1915 1994 && is_array($pending)
1916 1995 && (int) ($pending['revision'] ?? 0) !== $revision
1917 1996 ) {
1997 + // This attempt succeeded, so drop what it claimed: the newer change
1998 + // waits the grace a fresh edit gets, not a failure backoff it never
1999 + // earned. Not zero: that edit queued its own debounced event, and
2000 + // a marker due at once had the next admin request rebuild in its
2001 + // shutdown and the event rebuild again seconds later.
2002 + unset($pending['started'], $pending['memory_limit']);
2003 + $pending['attempts'] = 0;
2004 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2005 +
2006 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
1918 2007 return;
1919 2008 }
1920 2009
1921 2010 delete_option(self::REGENERATION_PENDING_OPTION);
@@ -1921,8 +2010,69 @@
1921 2010 delete_option(self::REGENERATION_PENDING_OPTION);
1922 2011 }
1923 2012
1924 2013 /**
2014 + * Record the attempt that is about to run before it runs.
2015 + *
2016 + * A PHP fatal — the memory limit or max_execution_time — is not a
2017 + * Throwable, so no catch or finally around the generation runs when the
2018 + * process dies, and a failure recorded afterwards was never recorded at
2019 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2020 + * started the same doomed rebuild again (a fatal every few minutes for as
2021 + * long as an admin was logged in). Claiming the attempt up front makes the
2022 + * backoff hold even when nothing after this line gets to run, and leaves a
2023 + * `started` stamp the next attempt can recognise as an interrupted one.
2024 + *
2025 + * @since 2.10.1
2026 + * @return void
2027 + */
2028 + private function claim_regeneration_attempt(): void {
2029 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2030 +
2031 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2032 + // there is no marker to retry from, so nothing to claim.
2033 + if (!is_array($pending) || empty($pending['since'])) {
2034 + return;
2035 + }
2036 +
2037 + if (!empty($pending['started'])) {
2038 + // The previous attempt claimed itself and never reported back.
2039 + update_option(
2040 + self::REGENERATION_ERROR_OPTION,
2041 + [
2042 + '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'),
2043 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2044 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2045 + 'time' => (int) $pending['started'],
2046 + ],
2047 + false
2048 + );
2049 + }
2050 +
2051 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2052 +
2053 + $pending['attempts'] = $attempts;
2054 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2055 + $pending['started'] = time();
2056 +
2057 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2058 + }
2059 +
2060 + /**
2061 + * Delay before the next takeover after the given number of attempts.
2062 + *
2063 + * @since 2.10.1
2064 + * @param int $attempts Attempts made so far (1 or more).
2065 + * @return int Seconds.
2066 + */
2067 + private function regeneration_backoff(int $attempts): int {
2068 + return (int) min(
2069 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2070 + self::REGENERATION_MAX_BACKOFF
2071 + );
2072 + }
2073 +
2074 + /**
1925 2075 * Record a failed regeneration instead of discarding it.
1926 2076 *
1927 2077 * Keeps the pending marker in place so the rebuild is retried, but backs the
1928 2078 * next attempt off exponentially (capped) so a persistently failing
@@ -1928,22 +2078,30 @@
1928 2078 * next attempt off exponentially (capped) so a persistently failing
1929 2079 * generation cannot run on every admin request.
1930 2080 *
1931 2081 * @since 2.2.1
1932 - * @param string $message Failure detail.
1933 - * @param string $source Either 'content' or 'settings'.
2082 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2083 + * does not count an attempt claim_regeneration_attempt()
2084 + * already counted.
2085 + * @param string $message Failure detail.
2086 + * @param string $source Either 'content' or 'settings'.
2087 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2088 + * short of, when that was the failure.
1934 2089 * @return void
1935 2090 */
1936 - private function record_regeneration_failure(string $message, string $source): void {
2091 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
1937 2092 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1938 2093 $pending = is_array($pending) ? $pending : [];
1939 - $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2094 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
1940 2095
1941 - $backoff = min(
1942 - self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1943 - self::REGENERATION_MAX_BACKOFF
1944 - );
2096 + // An attempt that claimed itself up front has already been counted.
2097 + if (empty($pending['started'])) {
2098 + $attempts++;
2099 + }
2100 + $attempts = max($attempts, 1);
1945 2101
2102 + $backoff = $this->regeneration_backoff($attempts);
2103 +
1946 2104 // Same precedence mark_regeneration_pending() enforces: a settings
1947 2105 // rebuild outranks a content one and must not be downgraded by a failed
1948 2106 // attempt. Overwriting it routed the retry back through the content
1949 2107 // path, where should_auto_generate() can be false and the completion
@@ -1952,20 +2110,24 @@
1952 2110 // whichever attempt actually failed.
1953 2111 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1954 2112 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1955 2113
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 - );
2114 + $marker = [
2115 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2116 + 'source' => $pending_source,
2117 + 'attempts' => $attempts,
2118 + 'next_attempt' => time() + $backoff,
2119 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2120 + ];
1967 2121
2122 + // Remembered so has_memory_for_retry() can keep requests with no more
2123 + // memory than this from repeating the same attempt.
2124 + if ($memory_limit !== null && $memory_limit > 0) {
2125 + $marker['memory_limit'] = $memory_limit;
2126 + }
2127 +
2128 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2129 +
1968 2130 update_option(
1969 2131 self::REGENERATION_ERROR_OPTION,
1970 2132 [
1971 2133 'message' => $message,
@@ -2020,16 +2182,34 @@
2020 2182 if (!self::has_overdue_regeneration()) {
2021 2183 return;
2022 2184 }
2023 2185
2024 - // Cron is running: it is about to do exactly this work.
2186 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2187 + $pending = is_array($pending) ? $pending : [];
2188 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2189 +
2190 + // Cron is running and its own event for this rebuild is still queued
2191 + // and due: it is about to do exactly this work. Only then — an event
2192 + // that has already been consumed (e.g. it fired while another process
2193 + // held the generation lock) is never re-queued, and returning here
2194 + // unconditionally left the rebuild to requests that could not finish it.
2025 2195 if (wp_doing_cron()) {
2196 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2197 + $next = wp_next_scheduled($hook);
2198 +
2199 + if ($next !== false && $next <= time()) {
2200 + return;
2201 + }
2202 + }
2203 +
2204 + // The last attempt had to stop short of this process's memory limit.
2205 + // Retrying at the same (or a lower) limit only repeats that, so leave
2206 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2207 + // host with a higher limit — instead of burning it on every request.
2208 + if (!$this->has_memory_for_retry($pending)) {
2026 2209 return;
2027 2210 }
2028 2211
2029 - $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2030 - $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
2031 -
2032 2212 if ($source === 'settings') {
2033 2213 $this->regenerate_sitemap_from_settings();
2034 2214 return;
2035 2215 }
@@ -2063,8 +2243,227 @@
2063 2243 delete_transient(self::GENERATION_LOCK_TRANSIENT);
2064 2244 }
2065 2245
2066 2246 /**
2247 + * This process's PHP memory limit in bytes.
2248 + *
2249 + * @since 2.10.1
2250 + * @return int Bytes, or -1 when unlimited (or unreadable).
2251 + */
2252 + private function current_memory_limit(): int {
2253 + $limit = (string) ini_get('memory_limit');
2254 +
2255 + if ($limit === '' || $limit === '-1') {
2256 + return -1;
2257 + }
2258 +
2259 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2260 +
2261 + return $bytes > 0 ? $bytes : -1;
2262 + }
2263 +
2264 + /**
2265 + * May this process retry a rebuild that last stopped at the memory limit?
2266 + *
2267 + * Raises the limit the way wp-admin does first, so a request that can get
2268 + * more room than the failed attempt had is still allowed to try.
2269 + *
2270 + * @since 2.10.1
2271 + * @param array $pending The pending marker.
2272 + * @return bool True when there is no recorded memory failure, or this
2273 + * process has more memory than the attempt that failed.
2274 + */
2275 + private function has_memory_for_retry(array $pending): bool {
2276 + if (empty($pending['memory_limit'])) {
2277 + return true;
2278 + }
2279 +
2280 + wp_raise_memory_limit('admin');
2281 +
2282 + $limit = $this->current_memory_limit();
2283 +
2284 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2285 + }
2286 +
2287 + /**
2288 + * Release what one walked chunk left behind.
2289 + *
2290 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2291 + * object and its meta in the in-process object cache, which nothing
2292 + * empties until the request ends. Unsetting the chunk therefore freed
2293 + * nothing, and the walk grew with the size of the site instead of the size
2294 + * of a chunk — about 1.3 GB on a 45k-post site.
2295 + *
2296 + * A persistent object cache that supports it drops only its in-process
2297 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2298 + * default cache has no shared store, and flushing it would empty every
2299 + * group for the rest of the request (options, the queried object, other
2300 + * plugins' data), so there only the chunk's own entries are deleted. A
2301 + * persistent cache without `flush_runtime` is left alone: deleting from it
2302 + * would evict the objects for every other request too.
2303 + *
2304 + * @since 2.10.1
2305 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2306 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2307 + * @param int[] $ids The chunk's object IDs, plus any objects it
2308 + * hydrated under their own (featured images).
2309 + * @return void
2310 + * @throws \Error See assert_memory_headroom().
2311 + */
2312 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2313 + // What one chunk costs before it is released: the margin the next one
2314 + // needs. Measured in the same real allocated size assert_memory_headroom()
2315 + // compares against the limit, so the two are the same unit.
2316 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2317 +
2318 + if (wp_using_ext_object_cache()) {
2319 + if (
2320 + function_exists('wp_cache_supports')
2321 + && wp_cache_supports('flush_runtime')
2322 + && function_exists('wp_cache_flush_runtime')
2323 + ) {
2324 + wp_cache_flush_runtime();
2325 + }
2326 + } elseif (!empty($ids)) {
2327 + foreach ($groups as $group) {
2328 + wp_cache_delete_multiple($ids, $group);
2329 + }
2330 + }
2331 +
2332 + $this->assert_memory_headroom();
2333 + }
2334 +
2335 + /**
2336 + * Cache groups get_posts() fills per post for the given post types.
2337 + *
2338 + * The post, its meta, and one relationships group per taxonomy the post
2339 + * type uses (update_object_term_cache()). Term objects themselves are
2340 + * bounded by the number of terms, not posts, so they are left cached.
2341 + *
2342 + * @since 2.10.1
2343 + * @param string[] $post_types Post types being walked.
2344 + * @return string[] Cache groups keyed by post ID.
2345 + */
2346 + private function chunk_post_cache_groups(array $post_types): array {
2347 + $groups = ['posts', 'post_meta'];
2348 +
2349 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2350 + $groups[] = $taxonomy . '_relationships';
2351 + }
2352 +
2353 + return array_values(array_unique($groups));
2354 + }
2355 +
2356 + /**
2357 + * Stop an automatic rebuild before the memory limit rather than at it.
2358 + *
2359 + * A PHP memory fatal skips every catch and finally, so the lock, the
2360 + * failure record and the backoff are all lost with it, while stopping here
2361 + * is an ordinary, fully recorded failure. Checked before each chunk is
2362 + * hydrated, against a margin of at least the largest chunk seen so far.
2363 + *
2364 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2365 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2366 + * swallow it and carry on — writing an index without the aborted segments
2367 + * and then pruning their files as orphans. Only the automatic rebuild's
2368 + * catch (\Throwable) is meant to see it.
2369 + *
2370 + * @since 2.10.1
2371 + * @return void
2372 + * @throws \Error When the automatic rebuild is close to the memory limit.
2373 + */
2374 + private function assert_memory_headroom(): void {
2375 + if (!$this->memory_guard) {
2376 + return;
2377 + }
2378 +
2379 + $limit = $this->current_memory_limit();
2380 + if ($limit === -1) {
2381 + return;
2382 + }
2383 +
2384 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2385 + // and a half of the costliest chunk if that is more — and never more
2386 + // than half the limit either way. Without that outer cap a single
2387 + // anomalously expensive chunk (500 posts of serialised page-builder or
2388 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2389 + // limit itself, so every later check aborts at any usage at all, the
2390 + // failure records this process's limit, and has_memory_for_retry()
2391 + // then refuses every process that has the same limit. A site that
2392 + // never actually ran out of memory would stop rebuilding until WP-CLI
2393 + // or a system cron happened to run.
2394 + $headroom = (int) min(
2395 + max(
2396 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2397 + $this->walk_chunk_cost * 1.5
2398 + ),
2399 + $limit * 0.5
2400 + );
2401 +
2402 + // The real allocated size, which is what PHP enforces memory_limit
2403 + // against; memory_get_usage(false) reports only what is handed out of
2404 + // those allocations and so understates the margin by the allocator's
2405 + // slack.
2406 + $usage = memory_get_usage(true);
2407 +
2408 + if ($usage > $limit - $headroom) {
2409 + throw new \Error(
2410 + sprintf(
2411 + /* translators: 1: memory in use, 2: PHP memory limit. */
2412 + __('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'),
2413 + size_format($usage),
2414 + size_format($limit)
2415 + ),
2416 + self::MEMORY_ABORT_CODE
2417 + );
2418 + }
2419 + }
2420 +
2421 + /**
2422 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2423 + *
2424 + * @since 2.10.1
2425 + * @param array $settings Sitemap settings.
2426 + * @return bool Whatever generate_and_save() returned.
2427 + * @throws \Throwable Whatever generation throws, after the memory guard is
2428 + * switched back off.
2429 + */
2430 + private function generate_for_regeneration(array $settings): bool {
2431 + // Same headroom wp-admin gives itself; a no-op when the limit is
2432 + // already higher or unlimited.
2433 + wp_raise_memory_limit('admin');
2434 +
2435 + $this->claim_regeneration_attempt();
2436 + $this->memory_guard = true;
2437 + $this->walk_chunk_cost = 0;
2438 +
2439 + try {
2440 + return $this->generate_and_save($settings);
2441 + } finally {
2442 + $this->memory_guard = false;
2443 + }
2444 + }
2445 +
2446 + /**
2447 + * Record a failure thrown by an automatic rebuild.
2448 + *
2449 + * @since 2.10.1
2450 + * @param \Throwable $e What was thrown.
2451 + * @param string $source Either 'content' or 'settings'.
2452 + * @return void
2453 + */
2454 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2455 + $memory_limit = null;
2456 +
2457 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2458 + $memory_limit = $this->current_memory_limit();
2459 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2460 + }
2461 +
2462 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2463 + }
2464 +
2465 + /**
2067 2466 * Report how automatic regeneration is faring, for the admin UI.
2068 2467 *
2069 2468 * The feature used to fail invisibly: `last_generated` simply stopped
2070 2469 * advancing and nothing drew attention to it (#629).
@@ -2162,16 +2561,20 @@
2162 2561 * auto_generate setting: the user deliberately changed inclusion rules and
2163 2562 * expects the served file to reflect them even if content-triggered
2164 2563 * auto-generation is turned off. Still respects the master `enabled` flag.
2165 2564 *
2166 - * @return void
2565 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2566 + * a caller can say so rather than assume it (#764). Existing
2567 + * callers that ignore the return are unaffected.
2568 + *
2569 + * @return bool True when the served sitemap now reflects the settings.
2167 2570 */
2168 - public function regenerate_sitemap_from_settings(): void {
2571 + public function regenerate_sitemap_from_settings(): bool {
2169 2572 if (!$this->acquire_generation_lock()) {
2170 2573 // A manual generation (or another request's takeover) is already
2171 2574 // writing the files; the pending marker survives so this rebuild is
2172 2575 // retried rather than lost.
2173 - return;
2576 + return false;
2174 2577 }
2175 2578
2176 2579 try {
2177 2580 $settings = $this->get_settings('site');
@@ -2178,30 +2581,56 @@
2178 2581 if (empty($settings['enabled'])) {
2179 2582 // The sitemap was disabled: remove the previously generated static
2180 2583 // files so the web server stops serving a stale sitemap that
2181 2584 // crawlers would otherwise keep fetching.
2182 - $this->delete_published_sitemaps();
2585 + //
2586 + // A file that could not be removed is still being served, so
2587 + // this is not a success. Reporting one here would tell a caller
2588 + // the sitemap was gone while the web server kept answering with
2589 + // it, which is the failure this return value exists to prevent
2590 + // (#764).
2591 + $removal = $this->delete_published_sitemaps($settings);
2592 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2593 +
2594 + if (!empty($stuck)) {
2595 + $this->record_regeneration_failure(
2596 + $this->stuck_files_message($stuck, true),
2597 + 'settings'
2598 + );
2599 +
2600 + return false;
2601 + }
2602 +
2183 2603 $this->mark_regeneration_complete();
2184 - return;
2604 +
2605 + return true;
2185 2606 }
2186 2607
2187 2608 $revision = $this->current_regeneration_revision();
2188 2609
2189 2610 if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2190 - $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2191 - return;
2611 + // Returns false when a static file is stuck in the web root:
2612 + // the server keeps serving that file in preference to WordPress,
2613 + // so the switch has not taken effect (#764).
2614 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2192 2615 }
2193 2616
2194 - if ($this->generate_and_save($settings)) {
2617 + if ($this->generate_for_regeneration($settings)) {
2195 2618 $this->mark_regeneration_complete($revision);
2196 - } else {
2197 - $this->record_regeneration_failure(
2198 - $this->write_failure_message(),
2199 - 'settings'
2200 - );
2619 +
2620 + return true;
2201 2621 }
2622 +
2623 + $this->record_regeneration_failure(
2624 + $this->write_failure_message(),
2625 + 'settings'
2626 + );
2627 +
2628 + return false;
2202 2629 } catch (\Throwable $e) {
2203 - $this->record_regeneration_failure($e->getMessage(), 'settings');
2630 + $this->record_thrown_regeneration_failure($e, 'settings');
2631 +
2632 + return false;
2204 2633 } finally {
2205 2634 $this->release_generation_lock();
2206 2635 }
2207 2636 }
@@ -2206,8 +2635,27 @@
2206 2635 }
2207 2636 }
2208 2637
2209 2638 /**
2639 + * When a rebuild has been outstanding since, or 0 when none is.
2640 + *
2641 + * Lets a caller report an honest "saved, but the served file has not caught
2642 + * up yet" instead of a bare success (#764).
2643 + *
2644 + * @since 2.10.0
2645 + * @return int Unix timestamp, or 0 when nothing is pending.
2646 + */
2647 + public static function regeneration_pending_since(): int {
2648 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2649 +
2650 + if (!is_array($pending) || empty($pending['since'])) {
2651 + return 0;
2652 + }
2653 +
2654 + return (int) $pending['since'];
2655 + }
2656 +
2657 + /**
2210 2658 * Remove every static sitemap file ThinkRank publishes to the web root.
2211 2659 *
2212 2660 * Called when the sitemap feature is disabled, by the cleanup route, and by
2213 2661 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -2314,9 +2762,9 @@
2314 2762 $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2315 2763 return;
2316 2764 }
2317 2765
2318 - if ($this->generate_and_save($settings)) {
2766 + if ($this->generate_for_regeneration($settings)) {
2319 2767 $this->mark_regeneration_complete($revision);
2320 2768 } else {
2321 2769 // Previously this returned quietly and last_generated simply
2322 2770 // stopped advancing, leaving the site owner with no way to learn
@@ -2326,9 +2774,9 @@
2326 2774 'content'
2327 2775 );
2328 2776 }
2329 2777 } catch (\Throwable $e) {
2330 - $this->record_regeneration_failure($e->getMessage(), 'content');
2778 + $this->record_thrown_regeneration_failure($e, 'content');
2331 2779 } finally {
2332 2780 $this->release_generation_lock();
2333 2781 }
2334 2782 }
@@ -2444,9 +2892,9 @@
2444 2892 * @param int $revision Revision this rebuild is completing.
2445 2893 * @param string $source 'settings' or 'content', for the failure record.
2446 2894 * @return void
2447 2895 */
2448 - private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): void {
2896 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2449 2897 $this->flush_dynamic_cache();
2450 2898
2451 2899 $removal = $this->delete_published_sitemaps($settings);
2452 2900 $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
@@ -2451,22 +2899,57 @@
2451 2899 $removal = $this->delete_published_sitemaps($settings);
2452 2900 $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2453 2901
2454 2902 if (!empty($stuck)) {
2455 - $this->record_regeneration_failure(
2456 - sprintf(
2457 - /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2458 - __('The sitemap is being served from WordPress, but these files are still in the site root and your web server will keep serving them instead: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2459 - implode(', ', $stuck),
2460 - untrailingslashit(ABSPATH)
2461 - ),
2462 - $source
2463 - );
2903 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2464 2904
2465 - return;
2905 + return false;
2466 2906 }
2467 2907
2468 2908 $this->mark_regeneration_complete($revision);
2909 +
2910 + return true;
2911 + }
2912 +
2913 + /**
2914 + * Why a stale file left in the web root means the change has not landed.
2915 + *
2916 + * Shared by every path that removes published files, so they cannot
2917 + * describe the same situation differently (#764).
2918 + *
2919 + * The two situations that reach it differ in what WordPress is doing, and
2920 + * the message has to say which. After a switch to dynamic delivery
2921 + * WordPress IS serving the sitemap and the files shadow it. After the
2922 + * sitemap is switched off WordPress serves nothing, so the one message
2923 + * used to tell a site owner who had just disabled the sitemap that it was
2924 + * "being served from WordPress", which is the opposite of what they did.
2925 + *
2926 + * @since 2.10.0
2927 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2928 + * takes $sitemap_disabled for the disabled path.
2929 + *
2930 + * @param string[] $stuck Basenames that could not be removed.
2931 + * @param bool $sitemap_disabled True when the files outlived disabling
2932 + * the sitemap rather than a switch to
2933 + * dynamic delivery.
2934 + * @return string
2935 + */
2936 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2937 + if ($sitemap_disabled) {
2938 + return sprintf(
2939 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2940 + __('The sitemap is disabled, but these files are still in the site root and your web server is still serving them: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2941 + implode(', ', $stuck),
2942 + untrailingslashit(ABSPATH)
2943 + );
2944 + }
2945 +
2946 + return sprintf(
2947 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2948 + __('The sitemap is being served from WordPress, but these files are still in the site root and your web server will keep serving them instead: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2949 + implode(', ', $stuck),
2950 + untrailingslashit(ABSPATH)
2951 + );
2469 2952 }
2470 2953
2471 2954 /**
2472 2955 * What to tell the site owner when publishing the files failed.