| @@ -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). |
| @@ -2214,9 +2613,9 @@ | ||
| 2214 | 2613 | // so the switch has not taken effect (#764). |
| 2215 | 2614 | return $this->switch_to_dynamic_delivery($settings, $revision, 'settings'); |
| 2216 | 2615 | } |
| 2217 | 2616 | |
| 2218 | - if ($this->generate_and_save($settings)) { | |
| 2617 | + if ($this->generate_for_regeneration($settings)) { | |
| 2219 | 2618 | $this->mark_regeneration_complete($revision); |
| 2220 | 2619 | |
| 2221 | 2620 | return true; |
| 2222 | 2621 | } |
| @@ -2227,9 +2626,9 @@ | ||
| 2227 | 2626 | ); |
| 2228 | 2627 | |
| 2229 | 2628 | return false; |
| 2230 | 2629 | } catch (\Throwable $e) { |
| 2231 | - $this->record_regeneration_failure($e->getMessage(), 'settings'); | |
| 2630 | + $this->record_thrown_regeneration_failure($e, 'settings'); | |
| 2232 | 2631 | |
| 2233 | 2632 | return false; |
| 2234 | 2633 | } finally { |
| 2235 | 2634 | $this->release_generation_lock(); |
| @@ -2363,9 +2762,9 @@ | ||
| 2363 | 2762 | $this->switch_to_dynamic_delivery($settings, $revision, 'content'); |
| 2364 | 2763 | return; |
| 2365 | 2764 | } |
| 2366 | 2765 | |
| 2367 | - if ($this->generate_and_save($settings)) { | |
| 2766 | + if ($this->generate_for_regeneration($settings)) { | |
| 2368 | 2767 | $this->mark_regeneration_complete($revision); |
| 2369 | 2768 | } else { |
| 2370 | 2769 | // Previously this returned quietly and last_generated simply |
| 2371 | 2770 | // stopped advancing, leaving the site owner with no way to learn |
| @@ -2375,9 +2774,9 @@ | ||
| 2375 | 2774 | 'content' |
| 2376 | 2775 | ); |
| 2377 | 2776 | } |
| 2378 | 2777 | } catch (\Throwable $e) { |
| 2379 | - $this->record_regeneration_failure($e->getMessage(), 'content'); | |
| 2778 | + $this->record_thrown_regeneration_failure($e, 'content'); | |
| 2380 | 2779 | } finally { |
| 2381 | 2780 | $this->release_generation_lock(); |
| 2382 | 2781 | } |
| 2383 | 2782 | } |