Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions includes/class-spacefast-static-publisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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' => '',
Expand Down Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'],
Expand All @@ -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'] ?? '' ),
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
Expand All @@ -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'],
Expand All @@ -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'],
Expand All @@ -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'] ?? '' ),
Expand Down
6 changes: 5 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions spacefast-wordpress.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down
60 changes: 59 additions & 1 deletion tests/acceptance/wordpress.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<h1>Acceptance</h1>' );
$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" );
95 changes: 95 additions & 0 deletions tests/behavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ) )
);
Expand Down
1 change: 1 addition & 0 deletions uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
Loading