diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index 0e24c12..e85c583 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -4,6 +4,7 @@ final class Spacefast_Static_Publisher { const OPTION = 'spacefast_wordpress_publish_state'; + const UPLOAD_OPTION = 'spacefast_wordpress_publish_upload'; // Consecutive transient failures tolerated before a publish gives up, with // an exponential wait between them (2s doubling, capped at a minute, about // four minutes in all). Simply Static runs background steps back to back, @@ -31,6 +32,11 @@ public static function step( $client = $client ?? new Spacefast_Client(); $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); + if ( array_key_exists( 'upload', $state ) ) { + self::save_upload( $state, ! empty( $state['resume_pending'] ) ); + } else { + $state['upload'] = get_option( self::UPLOAD_OPTION, null ); + } $resuming = (string) ( $state['archive_dir'] ?? '' ) === $root && (string) ( $state['publish_mode'] ?? '' ) === $publish_mode; $wait = (int) ( $state['retry_at'] ?? 0 ) - time(); @@ -61,7 +67,7 @@ public static function step( } $version_id = (string) ( $result['data']['versionId'] ?? '' ); if ( '' === $version_id ) { - delete_option( self::OPTION ); + self::reset(); return array( 'done' => true, 'version_id' => '', @@ -92,7 +98,7 @@ public static function step( 'pages' => 0, 'polls' => 0, ); - update_option( self::OPTION, $state, false ); + self::save_upload( $state ); if ( null === $upload ) { return self::begin_finalizing( $state ); } @@ -126,7 +132,7 @@ public static function step( $state['retries'] = 0; $state['next_target'] = $next_target + 1; $state['uploaded'] = (int) ( $state['uploaded'] ?? 0 ) + 1; - update_option( self::OPTION, $state, false ); + self::save_state( $state ); return array( 'done' => false, 'version_id' => (string) $state['version_id'], @@ -187,6 +193,30 @@ public static function manifest( string $archive_dir ): array { public static function reset(): void { delete_option( self::OPTION ); + delete_option( self::UPLOAD_OPTION ); + } + + private static function save_upload( array &$state, bool $resume_pending = false ): void { + // A crash between the receipt and cursor writes must refresh the page, + // never apply the previous page's cursor to the replacement targets. + $state['resume_pending'] = true; + self::save_state( $state ); + self::save_option( self::UPLOAD_OPTION, $state['upload'] ); + $state['resume_pending'] = $resume_pending; + self::save_state( $state ); + } + + private static function save_state( array $state ): void { + // Signed target pages can be megabytes. Per-file checkpoints must not + // rewrite them or Atomic's single-option write limiter blocks progress. + unset( $state['upload'] ); + self::save_option( self::OPTION, $state ); + } + + private static function save_option( string $name, $value ): void { + if ( ! update_option( $name, $value, false ) && get_option( $name, null ) !== $value ) { + throw new RuntimeException( 'Spacefast could not save publish progress. Ask your hosting support to check for blocked option updates before retrying.' ); + } } private static function ensure_public_config( string $root ): void { @@ -252,7 +282,7 @@ private static function resume( array $state, Spacefast_Client $client ): array $state['upload'] = $upload; $state['next_target'] = 0; $state['pages'] = $pages; - update_option( self::OPTION, $state, false ); + self::save_upload( $state ); return array( 'done' => false, 'version_id' => (string) $state['version_id'], @@ -270,7 +300,8 @@ private static function begin_finalizing( array $state ): array { $state['phase'] = 'finalizing'; $state['upload'] = null; $state['polls'] = 0; - update_option( self::OPTION, $state, false ); + self::save_state( $state ); + delete_option( self::UPLOAD_OPTION ); return array( 'done' => false, 'version_id' => (string) ( $state['version_id'] ?? '' ), @@ -309,7 +340,7 @@ private static function poll_version( array $state, Spacefast_Client $client ): $state['upload'] = $missing; $state['next_target'] = 0; $state['retries'] = 0; - update_option( self::OPTION, $state, false ); + self::save_upload( $state ); return array( 'done' => false, 'version_id' => (string) $state['version_id'], @@ -322,13 +353,13 @@ private static function poll_version( array $state, Spacefast_Client $client ): // status poll below reports what it settled to. $state['completion_confirmed'] = true; $state['retries'] = 0; - update_option( self::OPTION, $state, false ); + self::save_state( $state ); } $result = $client->get_static_version( (string) $state['version_id'] ); if ( ! $result['ok'] ) { if ( $result['retryable'] ) { $state['polls'] = $polls; - update_option( self::OPTION, $state, false ); + self::save_state( $state ); return array( 'done' => false, 'version_id' => (string) $state['version_id'], @@ -341,7 +372,7 @@ private static function poll_version( array $state, Spacefast_Client $client ): } $status = (string) ( $result['data']['status'] ?? '' ); if ( 'ready' === $status ) { - delete_option( self::OPTION ); + self::reset(); return array( 'done' => true, 'version_id' => (string) $state['version_id'], @@ -354,7 +385,7 @@ private static function poll_version( array $state, Spacefast_Client $client ): throw new RuntimeException( 'Spacefast could not publish the generated version.' ); } $state['polls'] = $polls; - update_option( self::OPTION, $state, false ); + self::save_state( $state ); return array( 'done' => false, 'version_id' => (string) $state['version_id'], @@ -379,7 +410,7 @@ private static function retry_or_throw( array $state, array $result, string $sta } $state['retries'] = $retries; $state['retry_at'] = time() + min( self::MAX_RETRY_DELAY_SECONDS, 2 ** $retries ); - update_option( self::OPTION, $state, false ); + self::save_state( $state ); return array( 'done' => false, 'version_id' => (string) ( $state['version_id'] ?? '' ), diff --git a/readme.txt b/readme.txt index a4ceb22..a7c4731 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: spacefast Tags: static site, headless cms, simply static, deployment Requires at least: 6.5 Requires PHP: 8.1 -Stable tag: 0.5.8 +Stable tag: 0.5.9 License: GPLv2 or later Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes. @@ -31,6 +31,10 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP == Changelog == += 0.5.9 = +* Store signed upload instructions separately from per-file progress so large exports do not repeatedly rewrite megabyte-sized options. +* Stop with a hosting-support error when publish progress cannot be saved instead of repeatedly uploading the same file. + = 0.5.8 = * Retry transient upload and upload-refresh failures in place, with exponential backoff for about four minutes, instead of restarting the whole export as a new version. * Once every file has uploaded, ask Spacefast which files it still lacks, upload any it names, and let that check trigger activation instead of depending on the upload-completion signal alone. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index 8b775fd..cb75c7e 100644 --- a/spacefast-wordpress.php +++ b/spacefast-wordpress.php @@ -3,7 +3,7 @@ * Plugin Name: Spacefast * Plugin URI: https://spacefast.com/ * Description: Publishes static WordPress exports or rebuilds a headless Spacefast site. - * Version: 0.5.8 + * Version: 0.5.9 * Update URI: https://github.com/spacefast/wordpress * Requires at least: 6.5 * Requires PHP: 8.1 @@ -14,7 +14,7 @@ defined( 'ABSPATH' ) || exit; -define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.8' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.9' ); define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ ); require_once __DIR__ . '/includes/class-spacefast-settings.php'; diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php index 4572378..48dd64b 100644 --- a/tests/acceptance/wordpress.php +++ b/tests/acceptance/wordpress.php @@ -113,8 +113,66 @@ function spacefast_accept( bool $condition, string $message ): void { ); $plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false ); -spacefast_accept( '0.5.8' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( '0.5.9' === $plugin['Version'], 'Unexpected plugin version.' ); spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); +$archive = sys_get_temp_dir() . '/spacefast-acceptance-' . wp_generate_uuid4(); +mkdir( $archive ); +file_put_contents( $archive . '/index.html', '

Acceptance

' ); +$upload_calls = 0; +$publish_client = new Spacefast_Client( + static function ( string $url, array $args ): array { + return array( + 'response' => array( 'code' => 201 ), + 'body' => wp_json_encode( + array( + 'versionId' => 'ver_acceptance', + 'upload' => array( + 'summary' => array( 'upload' => 2 ), + 'targets' => array_fill( 0, 2, array( + 'path' => 'index.html', + 'method' => 'PUT', + 'url' => 'https://uploads.example.test/file', + 'headers' => array( 'X-Signed' => str_repeat( 's', 1024 ) ), + ) ), + ), + ) + ), + ); + }, + static function () use ( &$upload_calls ): array { + $upload_calls++; + return array( 'response' => array( 'code' => 204 ), 'body' => '' ); + } +); +Spacefast_Static_Publisher::reset(); +$publish_step = Spacefast_Static_Publisher::step( $archive, $publish_client ); +spacefast_accept( 1 === $publish_step['uploaded'], 'The first file did not upload.' ); +spacefast_accept( ! isset( get_option( Spacefast_Static_Publisher::OPTION )['upload'] ), 'Progress still contains signed targets.' ); +$upload_autoload = $wpdb->get_var( $wpdb->prepare( + "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", + Spacefast_Static_Publisher::UPLOAD_OPTION +) ); +spacefast_accept( ! in_array( $upload_autoload, array( 'yes', 'on', 'auto-on' ), true ), 'Signed upload targets are autoloaded.' ); + +$block_progress = static fn ( $value, $old_value ) => $old_value; +add_filter( 'pre_update_option_' . Spacefast_Static_Publisher::OPTION, $block_progress, 10, 2 ); +$blocked = false; +try { + Spacefast_Static_Publisher::step( $archive, $publish_client ); +} catch ( RuntimeException $error ) { + $blocked = str_contains( $error->getMessage(), 'blocked option updates' ); +} +remove_filter( 'pre_update_option_' . Spacefast_Static_Publisher::OPTION, $block_progress, 10 ); +spacefast_accept( $blocked, 'A host-blocked progress write did not stop publishing.' ); +spacefast_accept( 1 === get_option( Spacefast_Static_Publisher::OPTION )['uploaded'], 'A blocked write incorrectly advanced the persisted cursor.' ); +$publish_step = Spacefast_Static_Publisher::step( $archive, $publish_client ); +spacefast_accept( 2 === $publish_step['uploaded'] && 3 === $upload_calls, 'Publishing did not resume from the last durable cursor.' ); +Spacefast_Static_Publisher::reset(); +spacefast_accept( false === get_option( Spacefast_Static_Publisher::UPLOAD_OPTION ), 'Reset retained signed upload targets.' ); +unlink( $archive . '/index.html' ); +unlink( $archive . '/sf.jsonc' ); +rmdir( $archive ); + Spacefast_Settings::disconnect(); fwrite( STDOUT, "Spacefast WordPress container acceptance: PASS\n" ); diff --git a/tests/behavior.php b/tests/behavior.php index 468f448..e893cee 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -74,6 +74,13 @@ function get_option( string $name, $default = false ) { return $GLOBALS['spacefast_options'][ $name ] ?? $default; } function update_option( string $name, $value ): bool { + if ( ! empty( $GLOBALS['spacefast_blocked_options'][ $name ] ) ) { + return false; + } + if ( ( $GLOBALS['spacefast_options'][ $name ] ?? null ) === $value ) { + return false; + } + $GLOBALS['spacefast_option_writes'][ $name ][] = strlen( serialize( $value ) ); $GLOBALS['spacefast_options'][ $name ] = $value; return true; } @@ -1018,6 +1025,94 @@ static function ( string $url, string $method, array $headers, string $file ) us 'uploads the next opaque target after resuming' ); +$large_targets = array(); +for ( $i = 0; $i < 1000; $i++ ) { + $large_targets[] = array( + 'path' => 'index.html', + 'method' => 'PUT', + 'url' => 'https://uploads.example.test/large-' . $i, + 'headers' => array( 'X-Signed' => str_repeat( 's', 1024 ) ), + ); +} +$large_upload = array( 'targets' => $large_targets, 'summary' => array( 'upload' => 1000 ) ); +$large_uploaded = array(); +$large_creates = 0; +$large_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( $large_upload, &$large_creates ): array { + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_large', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + if ( str_contains( $url, '/uploads/resume' ) ) { + return response( 200, array( 'upload' => null ) ); + } + $large_creates++; + return response( 201, array( 'versionId' => 'ver_large', 'upload' => $large_upload ) ); + }, + static function ( string $url ) use ( &$large_uploaded ): array { + $large_uploaded[] = $url; + return response( 204 ); + } +); +$GLOBALS['spacefast_option_writes'] = array(); +$large_step = Spacefast_Static_Publisher::step( $archive, $large_client ); +check( ! isset( get_option( Spacefast_Static_Publisher::OPTION )['upload'] ), 'large upload receipts are not embedded in progress checkpoints' ); +for ( $i = 1; $i < 1000; $i++ ) { + $large_step = Spacefast_Static_Publisher::step( $archive, $large_client ); +} +check( 1000 === $large_step['uploaded'] && 1000 === count( array_unique( $large_uploaded ) ), 'separate background steps upload every large-page target exactly once' ); +$checkpoint_sizes = $GLOBALS['spacefast_option_writes'][ Spacefast_Static_Publisher::OPTION ]; +check( max( $checkpoint_sizes ) < 2048 && array_sum( $checkpoint_sizes ) < 2 * 1024 * 1024, 'per-file checkpoint write volume stays small for a megabyte receipt' ); +check( 1 === count( $GLOBALS['spacefast_option_writes'][ Spacefast_Static_Publisher::UPLOAD_OPTION ] ), 'a large target page is written only once, not after every file' ); +Spacefast_Static_Publisher::step( $archive, $large_client ); +check( null === get_option( Spacefast_Static_Publisher::UPLOAD_OPTION, null ), 'signed upload instructions are removed before polling' ); +check( Spacefast_Static_Publisher::step( $archive, $large_client )['done'], 'a large export completes after compact checkpointing' ); +check( null === get_option( Spacefast_Static_Publisher::OPTION, null ), 'completion removes the progress checkpoint' ); + +$legacy_state = array( + 'archive_dir' => $archive, + 'publish_mode' => 'snapshot', + 'version_id' => 'ver_large', + 'upload' => $large_upload, + 'phase' => 'uploading', + 'next_target' => 998, + 'uploaded' => 998, + 'total' => 1000, +); +update_option( Spacefast_Static_Publisher::OPTION, $legacy_state ); +$legacy_step = Spacefast_Static_Publisher::step( $archive, $large_client ); +check( 999 === $legacy_step['uploaded'] && 1 === $large_creates, 'legacy inline state migrates without creating a new version or losing its cursor' ); +check( ! isset( get_option( Spacefast_Static_Publisher::OPTION )['upload'] ), 'migration removes the legacy inline receipt' ); + +$GLOBALS['spacefast_blocked_options'][ Spacefast_Static_Publisher::OPTION ] = true; +try { + Spacefast_Static_Publisher::step( $archive, $large_client ); + check( false, 'blocked progress writes must stop publishing' ); +} catch ( RuntimeException $error ) { + check( str_contains( $error->getMessage(), 'blocked option updates' ), 'blocked writes report an actionable error instead of repeating an upload indefinitely' ); +} +$GLOBALS['spacefast_blocked_options'] = array(); +Spacefast_Static_Publisher::reset(); +check( null === get_option( Spacefast_Static_Publisher::UPLOAD_OPTION, null ), 'reset removes the separate upload page' ); +$legacy_state['resume_pending'] = true; +update_option( Spacefast_Static_Publisher::OPTION, $legacy_state ); +$uploads_before_migration = count( $large_uploaded ); +$migrated_pending = Spacefast_Static_Publisher::step( $archive, $large_client ); +check( $uploads_before_migration === count( $large_uploaded ) && 'finalizing' === $migrated_pending['status'], 'legacy migration preserves a pending refresh instead of sending expired targets' ); +Spacefast_Static_Publisher::reset(); +$GLOBALS['spacefast_blocked_options'][ Spacefast_Static_Publisher::UPLOAD_OPTION ] = true; +$uploads_before_block = count( $large_uploaded ); +try { + Spacefast_Static_Publisher::step( $archive, $large_client ); + check( false, 'blocked target-page writes must stop publishing' ); +} catch ( RuntimeException $error ) { + check( $uploads_before_block === count( $large_uploaded ), 'a target-page save failure stops before any file uploads' ); + check( true === get_option( Spacefast_Static_Publisher::OPTION )['resume_pending'], 'an interrupted page replacement retains a durable refresh checkpoint' ); +} +$GLOBALS['spacefast_blocked_options'] = array(); +Spacefast_Static_Publisher::step( $archive, $large_client ); +check( $uploads_before_block === count( $large_uploaded ), 'an interrupted page replacement refreshes instead of reusing stale targets' ); +Spacefast_Static_Publisher::reset(); + Spacefast_Sync_State::save( array_merge( Spacefast_Sync_State::defaults(), array( 'desired' => 2, 'active_generation' => 1 ) ) ); diff --git a/uninstall.php b/uninstall.php index 751f686..58f306d 100644 --- a/uninstall.php +++ b/uninstall.php @@ -7,6 +7,7 @@ delete_option( 'spacefast_wordpress_sync_state' ); delete_option( 'spacefast_wordpress_worker_lock' ); delete_option( 'spacefast_wordpress_publish_state' ); +delete_option( 'spacefast_wordpress_publish_upload' ); delete_option( 'spacefast_wordpress_oauth_pending' ); delete_option( 'spacefast_wordpress_oauth_choices' ); delete_option( 'spacefast_wordpress_space_creation' );