diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index d21e9c5..0e24c12 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -4,6 +4,15 @@ final class Spacefast_Static_Publisher { const OPTION = 'spacefast_wordpress_publish_state'; + // 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, + // so without the wait every retry would land within the same second. + const MAX_TRANSIENT_RETRIES = 8; + const MAX_RETRY_DELAY_SECONDS = 60; + // Upload rounds the completion check may request before the publish gives + // up on files the runtime keeps reporting as missing. + const MAX_COMPLETION_ROUNDS = 3; /** * Advance one bounded step of a Simply Static publish. @@ -22,11 +31,21 @@ public static function step( $client = $client ?? new Spacefast_Client(); $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); - if ( - (string) ( $state['archive_dir'] ?? '' ) === $root - && (string) ( $state['publish_mode'] ?? '' ) === $publish_mode - && 'finalizing' === ( $state['phase'] ?? '' ) - ) { + $resuming = (string) ( $state['archive_dir'] ?? '' ) === $root + && (string) ( $state['publish_mode'] ?? '' ) === $publish_mode; + $wait = (int) ( $state['retry_at'] ?? 0 ) - time(); + if ( $resuming && $wait > 0 ) { + // Hold the background step briefly instead of spinning it. + sleep( min( $wait, 1 ) ); + return array( + 'done' => false, + 'version_id' => (string) ( $state['version_id'] ?? '' ), + 'uploaded' => (int) ( $state['uploaded'] ?? 0 ), + 'total' => (int) ( $state['total'] ?? 0 ), + 'status' => 'finalizing' === ( $state['phase'] ?? '' ) ? 'finalizing' : 'uploading', + ); + } + if ( $resuming && 'finalizing' === ( $state['phase'] ?? '' ) ) { return self::poll_version( $state, $client ); } @@ -79,6 +98,11 @@ public static function step( } } + // An expired upload session whose refresh failed transiently: refresh + // again before sending bytes on the stale targets. + if ( ! empty( $state['resume_pending'] ) ) { + return self::resume( $state, $client ); + } $upload = $state['upload'] ?? null; if ( ! is_array( $upload ) ) { return self::begin_finalizing( $state ); @@ -94,10 +118,12 @@ public static function step( $result = $client->upload_static_file( $target, $path ); if ( ! $result['ok'] ) { if ( in_array( $result['code'], array( 'upload_http_401', 'upload_http_403' ), true ) ) { + $state['resume_pending'] = true; return self::resume( $state, $client ); } - throw new RuntimeException( $result['message'] ); + return self::retry_or_throw( $state, $result, 'uploading' ); } + $state['retries'] = 0; $state['next_target'] = $next_target + 1; $state['uploaded'] = (int) ( $state['uploaded'] ?? 0 ) + 1; update_option( self::OPTION, $state, false ); @@ -212,8 +238,10 @@ private static function resume( array $state, Spacefast_Client $client ): array } $result = $client->resume_static_upload( (string) $state['version_id'] ); if ( ! $result['ok'] ) { - throw new RuntimeException( $result['message'] ); + return self::retry_or_throw( $state, $result, 'uploading' ); } + $state['retries'] = 0; + $state['resume_pending'] = false; $upload = $result['data']['upload'] ?? null; if ( null !== $upload && ! is_array( $upload ) ) { throw new RuntimeException( 'Spacefast returned invalid upload instructions.' ); @@ -261,6 +289,41 @@ private static function poll_version( array $state, Spacefast_Client $client ): if ( $polls > 300 ) { throw new RuntimeException( 'Spacefast did not finish publishing the version.' ); } + if ( empty( $state['completion_confirmed'] ) ) { + // Ask Spacefast which files it still lacks. When none are missing, the + // refresh runs the same completion the runtime's upload callback runs, + // so a lost callback cannot strand the version; when some are, upload + // them before waiting. + $refresh = $client->resume_static_upload( (string) $state['version_id'] ); + if ( ! $refresh['ok'] && $refresh['retryable'] ) { + return self::retry_or_throw( $state, $refresh, 'finalizing' ); + } + $missing = $refresh['ok'] ? ( $refresh['data']['upload'] ?? null ) : null; + if ( is_array( $missing ) && ! empty( $missing['targets'] ) ) { + $rounds = (int) ( $state['completion_rounds'] ?? 0 ) + 1; + if ( $rounds > self::MAX_COMPLETION_ROUNDS ) { + throw new RuntimeException( 'Spacefast kept reporting uploaded files as missing.' ); + } + $state['completion_rounds'] = $rounds; + $state['phase'] = 'uploading'; + $state['upload'] = $missing; + $state['next_target'] = 0; + $state['retries'] = 0; + update_option( self::OPTION, $state, false ); + return array( + 'done' => false, + 'version_id' => (string) $state['version_id'], + 'uploaded' => (int) $state['uploaded'], + 'total' => (int) $state['total'], + 'status' => 'uploading', + ); + } + // A refusal means the version already left the draft states; the + // status poll below reports what it settled to. + $state['completion_confirmed'] = true; + $state['retries'] = 0; + update_option( self::OPTION, $state, false ); + } $result = $client->get_static_version( (string) $state['version_id'] ); if ( ! $result['ok'] ) { if ( $result['retryable'] ) { @@ -300,4 +363,29 @@ private static function poll_version( array $state, Spacefast_Client $client ): 'status' => 'finalizing', ); } + + /** + * Keep the publish state and try the same step again on a transient failure, + * up to MAX_TRANSIENT_RETRIES in a row. Anything else ends the publish. + * + * @param array $state State. + * @param array{ok:bool,retryable:bool,code:string,message:string,data:array} $result Failed result. + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + */ + private static function retry_or_throw( array $state, array $result, string $status ): array { + $retries = (int) ( $state['retries'] ?? 0 ) + 1; + if ( ! $result['retryable'] || $retries > self::MAX_TRANSIENT_RETRIES ) { + throw new RuntimeException( $result['message'] ); + } + $state['retries'] = $retries; + $state['retry_at'] = time() + min( self::MAX_RETRY_DELAY_SECONDS, 2 ** $retries ); + update_option( self::OPTION, $state, false ); + return array( + 'done' => false, + 'version_id' => (string) ( $state['version_id'] ?? '' ), + 'uploaded' => (int) ( $state['uploaded'] ?? 0 ), + 'total' => (int) ( $state['total'] ?? 0 ), + 'status' => $status, + ); + } } diff --git a/readme.txt b/readme.txt index 021bb43..a4ceb22 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.7 +Stable tag: 0.5.8 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.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. + = 0.5.7 = * Include public custom post type archive routes in Simply Static exports. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index 0b65d4d..8b775fd 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.7 + * Version: 0.5.8 * 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.7' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.8' ); 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 dc620bd..4572378 100644 --- a/tests/acceptance/wordpress.php +++ b/tests/acceptance/wordpress.php @@ -113,7 +113,7 @@ 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.7' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( '0.5.8' === $plugin['Version'], 'Unexpected plugin version.' ); spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); Spacefast_Settings::disconnect(); diff --git a/tests/behavior.php b/tests/behavior.php index 8cd742c..468f448 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -220,6 +220,12 @@ function response( int $status, array $data = array() ): array { ); } +function skip_publish_retry_wait(): void { + $state = get_option( Spacefast_Static_Publisher::OPTION, array() ); + $state['retry_at'] = 0; + update_option( Spacefast_Static_Publisher::OPTION, $state ); +} + function paginated_response( array $data, ?string $next_cursor = null ): array { return array( 'response' => array( 'code' => 200 ), @@ -927,6 +933,10 @@ static function ( string $url, array $args ) use ( &$static_requests ): array { ); } if ( str_contains( $url, '/uploads/resume' ) ) { + // The completion check after the last page finds nothing missing. + if ( count( array_filter( $static_requests, static fn ( array $request ): bool => str_contains( $request[0], '/uploads/resume' ) ) ) > 1 ) { + return response( 200, array( 'upload' => null ) ); + } return response( 200, array( @@ -1084,6 +1094,181 @@ public function run_static_export() { self::$runs++; return true; } Spacefast_Plugin::static_publish_progress( 'uploading' ); check( 'uploading' === Spacefast_Sync_State::get()['last_status'], 'static upload progress refreshes the delivery heartbeat' ); +Spacefast_Static_Publisher::reset(); +$retry_requests = array(); +$retry_uploads = 0; +$retry_target = array( + 'path' => 'index.html', + 'method' => 'PUT', + 'url' => 'https://uploads.example.test/retry', + 'headers' => array(), +); +$retry_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$retry_requests, $retry_target ): array { + $retry_requests[] = array( $url, $args['method'] ); + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_retry', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + if ( str_contains( $url, '/uploads/resume' ) ) { + return response( 200, array( 'upload' => null ) ); + } + return response( + 201, + array( + 'versionId' => 'ver_retry', + 'upload' => array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ), + ) + ); + }, + static function () use ( &$retry_uploads ): array { + $retry_uploads++; + return response( 1 === $retry_uploads ? 503 : 204 ); + } +); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( false === $retry_step['done'] && 0 === $retry_step['uploaded'], 'a transient upload failure keeps the publish running' ); +check( 'ver_retry' === $retry_step['version_id'], 'a transient upload failure keeps the same Spacefast version' ); +$retry_state = get_option( Spacefast_Static_Publisher::OPTION, array() ); +check( $retry_state['retry_at'] > time(), 'a transient failure schedules its retry instead of firing it at once' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 1 === $retry_uploads && 0 === $retry_step['uploaded'], 'a step inside the retry wait sends nothing' ); +skip_publish_retry_wait(); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 1 === $retry_step['uploaded'] && 2 === $retry_uploads, 'the next step retries the same upload target' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 'finalizing' === $retry_step['status'], 'a finished upload waits for activation' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( true === $retry_step['done'] && 'live' === $retry_step['status'], 'the retried publish goes live' ); +$retry_creates = array_filter( + $retry_requests, + static fn ( array $request ): bool => 'POST' === $request[1] && str_ends_with( $request[0], '/versions' ) +); +$retry_confirms = array_filter( + $retry_requests, + static fn ( array $request ): bool => str_contains( $request[0], '/versions/ver_retry/uploads/resume' ) +); +$retry_finalizes = array_filter( + $retry_requests, + static fn ( array $request ): bool => str_contains( $request[0], '/finalize' ) +); +check( 1 === count( $retry_creates ), 'a retried publish never declares a second version' ); +check( 1 === count( $retry_confirms ), 'a finished upload confirms the missing set once before waiting' ); +check( 0 === count( $retry_finalizes ), 'the plugin never calls finalize, which its scopes do not grant' ); + +Spacefast_Static_Publisher::reset(); +$expired_uploads = 0; +$expired_resumes = 0; +$expired_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$expired_resumes, $retry_target ): array { + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_expired', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + $expired_resumes++; + if ( 1 === $expired_resumes ) return response( 503 ); + return response( 200, array( 'upload' => 2 === $expired_resumes ? $upload : null ) ); + } + return response( 201, array( 'versionId' => 'ver_expired', 'upload' => $upload ) ); + }, + static function () use ( &$expired_uploads ): array { + $expired_uploads++; + return response( 1 === $expired_uploads ? 401 : 204 ); + } +); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( false === $expired_step['done'] && 1 === $expired_resumes, 'an expired upload session asks for fresh targets' ); +skip_publish_retry_wait(); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( 2 === $expired_resumes && 1 === $expired_uploads, 'a failed refresh is retried before any stale target is sent again' ); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( 2 === $expired_uploads && 1 === $expired_step['uploaded'], 'the refreshed target uploads' ); + +Spacefast_Static_Publisher::reset(); +$missing_uploads = 0; +$missing_resumes = 0; +$missing_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$missing_resumes, $retry_target ): array { + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_missing', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + $missing_resumes++; + return response( 200, array( 'upload' => 1 === $missing_resumes ? $upload : null ) ); + } + return response( 201, array( 'versionId' => 'ver_missing', 'upload' => $upload ) ); + }, + static function () use ( &$missing_uploads ): array { + $missing_uploads++; + return response( 204 ); + } +); +for ( $i = 0; $i < 6; $i++ ) { + $missing_step = Spacefast_Static_Publisher::step( $archive, $missing_client ); + if ( $missing_step['done'] ) break; +} +check( true === $missing_step['done'] && 2 === $missing_uploads, 'a file the server still lacks is uploaded again before waiting' ); +check( 2 === $missing_resumes, 'the completion check runs again after the missing file lands' ); + +Spacefast_Static_Publisher::reset(); +$stuck_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( $retry_target ): array { + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + return response( 200, array( 'upload' => $upload ) ); + } + return response( 201, array( 'versionId' => 'ver_stuck', 'upload' => $upload ) ); + }, + static fn (): array => response( 204 ) +); +$stuck = false; +try { + for ( $i = 0; $i < 20; $i++ ) { + Spacefast_Static_Publisher::step( $archive, $stuck_client ); + } +} catch ( RuntimeException $error ) { + $stuck = 'Spacefast kept reporting uploaded files as missing.' === $error->getMessage(); +} +check( $stuck, 'a file the runtime never records ends the publish after bounded upload rounds' ); + +Spacefast_Static_Publisher::reset(); +$rejecting_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( $retry_target ): array { + return response( + 201, + array( + 'versionId' => 'ver_rejected', + 'upload' => array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ), + ) + ); + }, + static fn (): array => response( 400 ) +); +$rejected = false; +try { + Spacefast_Static_Publisher::step( $archive, $rejecting_client ); +} catch ( RuntimeException $error ) { + $rejected = 'Spacefast rejected a generated file upload.' === $error->getMessage(); +} +check( $rejected, 'a rejected upload still ends the publish' ); +Spacefast_Static_Publisher::reset(); + Spacefast_Settings::merge( $connection ); unlink( $archive . '/assets/app.js' );