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' );