Skip to content
Open

Release #1169

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
58 changes: 58 additions & 0 deletions assets/src/dashboard/utils/__tests__/api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* callSync() polls the progress of an Image Storage transfer. apiFetch rejects on any non-2xx
* response or network error; every loading flag callSync() set must then be cleared, or the
* Image Storage controls (and other screens) stay disabled.
*/
const mockActions = {};

jest.mock( '@wordpress/api-fetch', () => jest.fn() );
jest.mock( '@wordpress/data', () => ({
dispatch: () => new Proxy({}, {
get: ( target, name ) => {
if ( ! mockActions[ name ]) {
mockActions[ name ] = jest.fn();
}
return mockActions[ name ];
}
}),
select: () => ({
getOptimizedImages: () => [],
getQueryArgs: () => ({}),
getTotalNumberOfImages: () => 0,
getSiteSettings: () => ({})
})
}) );
jest.mock( '@wordpress/url', () => ({ addQueryArgs: url => url }) );

// Provided by WordPress at runtime, not installed.
jest.mock( '@wordpress/notices', () => ({ store: 'core/notices' }), { virtual: true });
jest.mock( '../helpers', () => ({ toggleDashboardSidebarSubmenu: jest.fn() }) );

global.optimoleDashboardApp = {
nonce: 'nonce',
routes: { number_of_images_and_pages: '/optml/v1/number_of_images_and_pages' }
};
global.jQuery = { ajax: jest.fn() };

const apiFetch = require( '@wordpress/api-fetch' );
const { callSync } = require( '../api' );

const flushPromises = () => new Promise( resolve => setTimeout( resolve, 0 ) );

describe( 'callSync', () => {
beforeEach( () => {
Object.values( mockActions ).forEach( action => action.mockClear() );
});

test.each([ 'offload_images', 'rollback_images' ])( 'clears the loading state when the %s status request fails', async action => {
apiFetch.mockReturnValueOnce( Promise.reject( new Error( 'Internal Server Error' ) ) );

callSync({ action });
await flushPromises();

expect( mockActions.setErrorMedia ).toHaveBeenCalledWith( action );
expect( mockActions.setIsLoading.mock.calls ).toEqual([[ true ], [ false ]]);
expect( mockActions.setLoadingSync ).toHaveBeenLastCalledWith( false );
expect( mockActions.setLoadingRollback ).toHaveBeenLastCalledWith( false );
});
});
1 change: 1 addition & 0 deletions assets/src/dashboard/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ export const callSync = ( data ) => {
setErrorMedia( data.action );
setLoadingSync( false );
setLoadingRollback( false );
setIsLoading( false );
});
};

Expand Down
31 changes: 21 additions & 10 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions inc/cli/cli_media.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,16 @@ private function update_images_template( $action ) {
'limit_exceeded' => __( 'You have reached the maximum offloading limit of images. To increase the offload limit and for more information, contact our support.', 'optimole-wp' ),
];
$settings = new Optml_Settings();
if ( $settings->get( 'offload_media' ) === 'disabled' ) {
if ( ! $settings->can_transfer_images( $action ) ) {
return \WP_CLI::error( __( 'You need to have the offload_media option enabled in order to use this command', 'optimole-wp' ) );
}

// One run moves images at a time. A background step that is moving images right now holds this
// lock; while this command holds it, the background steps do nothing.
if ( ! Optml_Media_Offload::acquire_moving_images_lock() ) {
return \WP_CLI::error( 'Another image transfer is running. Wait for it to finish, then run this command again.' );
}

Optml_Media_Offload::clear_offload_errors_meta();
if ( $action === 'rollback' ) {
Optml_Media_Offload::clear_rollback_errors_meta();
Expand All @@ -60,6 +66,7 @@ private function update_images_template( $action ) {
Optml_Media_Offload::record_process_meta( $number_of_images );
$batch = 5;
if ( $number_of_images === 0 ) {
Optml_Media_Offload::release_moving_images_lock();
WP_CLI::success( $strings[ $action ]['success'] );

return;
Expand Down Expand Up @@ -88,14 +95,17 @@ private function update_images_template( $action ) {
$action === 'rollback' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch );
}

if ( Optml_Media_Offload::instance()->settings->is_offload_limit_reached() ) {
// The offload limit stops uploads only: moving images back lowers the usage.
if ( $action === 'offload' && Optml_Media_Offload::instance()->settings->is_offload_limit_reached() ) {
Optml_Media_Offload::release_moving_images_lock();
WP_CLI::error( $strings['limit_exceeded'], true );
}

$progress->tick();
++$tick;
}
$progress->finish();
Optml_Media_Offload::release_moving_images_lock();
WP_CLI::line( $strings[ $action ]['success'] );
}
}
92 changes: 85 additions & 7 deletions inc/media_offload.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,14 @@ public static function instance() {
self::$instance->init();
}
if ( self::$instance->settings->is_offload_enabled() ) {
// While a rollback runs, offloaded images are still served from Optimole, but new uploads
// stay local: offloading them and their filename deduplication needs offload_media on.
$offload_new_uploads = self::$instance->settings->get( 'offload_media' ) === 'enabled';

add_filter( 'image_downsize', [ self::$instance, 'generate_filter_downsize_urls' ], 10, 3 );
add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 );
if ( $offload_new_uploads ) {
add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 );
}
add_filter( 'wp_get_attachment_url', [ self::$instance, 'get_image_attachment_url' ], - 999, 2 );
add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_uploaded_images' ] );

Expand All @@ -176,12 +182,15 @@ public static function instance() {
add_filter( 'wp_calculate_image_srcset', [ self::$instance, 'calculate_image_srcset' ], 1, 5 );
add_action( 'post_updated', [ self::$instance, 'update_offload_meta' ], 10, 3 );

// Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
$below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
if ( $below_6_0_0 ) {
add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 );
} else {
add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 );
// Local uploads keep WordPress's own filename deduplication.
if ( $offload_new_uploads ) {
// Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
$below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
if ( $below_6_0_0 ) {
add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 );
} else {
add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 );
}
}

add_action( 'optml_start_processing_images', [ self::$instance, 'start_processing_images' ], 10, 6 );
Expand Down Expand Up @@ -2043,6 +2052,65 @@ public static function renew_transfer_lock( $token, $action ) {
return true;
}

/**
* Take the lock of the run that is moving images right now, without waiting.
*
* The transfer lock above says which chain owns a transfer. It is taken when a chain is scheduled,
* and again on every request while a transfer is in progress, so it cannot tell whether images are
* being moved at this moment. This one is a database advisory lock: taking it is atomic, and the
* database frees it when the request ends, also after a crash. A background step holds it for its
* batch, and WP-CLI for its whole run, so the two never move the same images at the same time.
*
* @return bool False while another run holds it. True when it was taken, and also where the
* database has no advisory locks, so transfers work there as before.
*/
public static function acquire_moving_images_lock(): bool {
global $wpdb;

// The lock lives on one database server. Where reads are spread over several (HyperDB,
// LudicrousDB), every run has to ask the same one: the primary.
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
$wpdb->send_reads_to_masters();
}

$suppress = $wpdb->suppress_errors();
$result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, 0 )', self::get_moving_images_lock_name() ) );
$wpdb->suppress_errors( $suppress );

// A fatal error skips the cleanup of the caller. On a persistent connection the lock would then
// outlive the request, so it is also released when the request shuts down.
add_action( 'shutdown', [ __CLASS__, 'release_moving_images_lock' ] );

return null === $result || 1 === (int) $result;
}

/**
* Release the lock taken with {@see self::acquire_moving_images_lock()}.
*
* @return void
*/
public static function release_moving_images_lock(): void {
global $wpdb;

$suppress = $wpdb->suppress_errors();
$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', self::get_moving_images_lock_name() ) );
$wpdb->suppress_errors( $suppress );
}

/**
* Name of the advisory lock. Such locks are shared by every database on the server, so the name
* holds the database and the table prefix (which is per site on a network).
*
* @return string
*/
public static function get_moving_images_lock_name(): string {
global $wpdb;

$database = defined( 'DB_NAME' ) ? DB_NAME : '';

return 'optml_moving_images_' . md5( $database . $wpdb->prefix );
}

/**
* Release the transfer lock if we still own it, allowing another worker to acquire it.
*
Expand Down Expand Up @@ -2151,6 +2219,14 @@ public function start_processing_images( $action, $batch, $page, $total, $step,
return;
}

// Only one run moves images at a time. WP-CLI holds this lock while its transfer runs: it does the
// work of this step, and the chain is scheduled again once the transfer lock expires.
if ( ! self::acquire_moving_images_lock() ) {
self::$instance->logger->add_log( $type, 'Another run is moving images, this step is skipped.' );

return;
}

set_time_limit( 0 );

try {
Expand Down Expand Up @@ -2201,6 +2277,8 @@ public function start_processing_images( $action, $batch, $page, $total, $step,
$lock_token,
]
);
} finally {
self::release_moving_images_lock();
}
}

Expand Down
5 changes: 5 additions & 0 deletions inc/rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,11 @@ public function disconnect( WP_REST_Request $request ) {
* @return WP_REST_Response
*/
public function poll_optimized_images( WP_REST_Request $request ) {
// The image URLs are built with the Optimole SDK, which is only initialized for a connected site.
if ( ! ( new Optml_Settings() )->is_connected() ) {
return $this->response( [] );
}

$api_key = $request->get_param( 'api_key' );
$request = new Optml_Api();
$images = $request->get_optimized_images( $api_key );
Expand Down
17 changes: 17 additions & 0 deletions inc/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,23 @@ public function is_offload_enabled() {
return $this->get( 'offload_media' ) === 'enabled' || $this->get( 'rollback_status' ) !== 'disabled';
}

/**
* Whether a bulk image transfer can run. Moving images to Optimole needs offload_media on.
* Moving them back is also allowed while a rollback is in progress: "Transfer back" in the
* dashboard turns offload_media off when it starts the rollback.
*
* @param string $action The transfer, 'offload' or 'rollback'.
*
* @return bool
*/
public function can_transfer_images( $action ) {
if ( 'rollback' === $action ) {
return $this->is_offload_enabled();
}

return $this->get( 'offload_media' ) !== 'disabled';
}

/**
* Get cloud sites whitelist for current domain only.
*
Expand Down
Loading
Loading