Skip to content

Commit 8245308

Browse files
REST API: Move sideload metadata writing to the finalize endpoint
Backport of Gutenberg PR #75888. Eliminate the read-modify-write race between concurrent sideloads for the same attachment by no longer writing attachment metadata in the sideload endpoint. Instead, sideload returns lightweight sub-size data (dimensions, filename, filesize) which the client accumulates and passes to the finalize endpoint, which writes all collected sub-sizes in a single metadata update. This matches how core generates sub-sizes (one metadata write after all sizes exist) and replaces the earlier per-attachment locking approach that the merged Gutenberg PR ultimately abandoned.
1 parent 238d92e commit 8245308

2 files changed

Lines changed: 159 additions & 44 deletions

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

Lines changed: 88 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,46 @@ public function register_routes() {
113113
'callback' => array( $this, 'finalize_item' ),
114114
'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
115115
'args' => array(
116-
'id' => array(
116+
'id' => array(
117117
'description' => __( 'Unique identifier for the attachment.' ),
118118
'type' => 'integer',
119119
),
120+
'sub_sizes' => array(
121+
'description' => __( 'Array of sub-size metadata collected from sideload responses.' ),
122+
'type' => 'array',
123+
'default' => array(),
124+
'items' => array(
125+
'type' => 'object',
126+
'properties' => array(
127+
'image_size' => array(
128+
'type' => 'string',
129+
'required' => true,
130+
),
131+
'width' => array(
132+
'type' => 'integer',
133+
'minimum' => 1,
134+
),
135+
'height' => array(
136+
'type' => 'integer',
137+
'minimum' => 1,
138+
),
139+
'file' => array(
140+
'type' => 'string',
141+
),
142+
'mime_type' => array(
143+
'type' => 'string',
144+
'pattern' => '^image/.*',
145+
),
146+
'filesize' => array(
147+
'type' => 'integer',
148+
'minimum' => 1,
149+
),
150+
'original_image' => array(
151+
'type' => 'string',
152+
),
153+
),
154+
),
155+
),
120156
),
121157
),
122158
'allow_batch' => $this->allow_batch,
@@ -2082,16 +2118,19 @@ public function sideload_item( WP_REST_Request $request ) {
20822118

20832119
$image_size = $request['image_size'];
20842120

2085-
$metadata = wp_get_attachment_metadata( $attachment_id, true );
2086-
2087-
if ( ! $metadata ) {
2088-
$metadata = array();
2089-
}
2121+
// Build sub-size data to return to the client.
2122+
// The client accumulates these and sends them all to the finalize
2123+
// endpoint, which writes the metadata in a single operation. This
2124+
// avoids the read-modify-write race that concurrent sideloads for the
2125+
// same attachment would otherwise hit.
2126+
$sub_size_data = array(
2127+
'image_size' => $image_size,
2128+
);
20902129

20912130
if ( 'original' === $image_size ) {
2092-
$metadata['original_image'] = wp_basename( $path );
2131+
$sub_size_data['file'] = wp_basename( $path );
20932132
} elseif ( 'scaled' === $image_size ) {
2094-
// The current attached file is the original; record it as original_image.
2133+
// Record the current attached file as the original.
20952134
$current_file = get_attached_file( $attachment_id, true );
20962135

20972136
if ( ! $current_file ) {
@@ -2102,7 +2141,7 @@ public function sideload_item( WP_REST_Request $request ) {
21022141
);
21032142
}
21042143

2105-
$metadata['original_image'] = wp_basename( $current_file );
2144+
$sub_size_data['original_image'] = wp_basename( $current_file );
21062145

21072146
// Validate the scaled image before updating the attached file.
21082147
$size = wp_getimagesize( $path );
@@ -2117,6 +2156,7 @@ public function sideload_item( WP_REST_Request $request ) {
21172156
}
21182157

21192158
// Update the attached file to point to the scaled version.
2159+
// This writes to _wp_attached_file meta, not _wp_attachment_metadata.
21202160
if (
21212161
get_attached_file( $attachment_id, true ) !== $path &&
21222162
! update_attached_file( $attachment_id, $path )
@@ -2128,42 +2168,21 @@ public function sideload_item( WP_REST_Request $request ) {
21282168
);
21292169
}
21302170

2131-
$metadata['width'] = $size[0];
2132-
$metadata['height'] = $size[1];
2133-
$metadata['filesize'] = $filesize;
2134-
$metadata['file'] = _wp_relative_upload_path( $path );
2171+
$sub_size_data['width'] = $size[0];
2172+
$sub_size_data['height'] = $size[1];
2173+
$sub_size_data['filesize'] = $filesize;
2174+
$sub_size_data['file'] = _wp_relative_upload_path( $path );
21352175
} else {
2136-
$metadata['sizes'] = $metadata['sizes'] ?? array();
2137-
21382176
$size = wp_getimagesize( $path );
21392177

2140-
$metadata['sizes'][ $image_size ] = array(
2141-
'width' => $size ? $size[0] : 0,
2142-
'height' => $size ? $size[1] : 0,
2143-
'file' => wp_basename( $path ),
2144-
'mime-type' => $type,
2145-
'filesize' => wp_filesize( $path ),
2146-
);
2147-
}
2148-
2149-
wp_update_attachment_metadata( $attachment_id, $metadata );
2150-
2151-
$response_request = new WP_REST_Request(
2152-
WP_REST_Server::READABLE,
2153-
rest_get_route_for_post( $attachment_id )
2154-
);
2155-
2156-
$response_request['context'] = 'edit';
2157-
2158-
if ( isset( $request['_fields'] ) ) {
2159-
$response_request['_fields'] = $request['_fields'];
2178+
$sub_size_data['width'] = $size ? $size[0] : 0;
2179+
$sub_size_data['height'] = $size ? $size[1] : 0;
2180+
$sub_size_data['file'] = wp_basename( $path );
2181+
$sub_size_data['mime_type'] = $type;
2182+
$sub_size_data['filesize'] = wp_filesize( $path );
21602183
}
21612184

2162-
$response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
2163-
2164-
$response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
2165-
2166-
return $response;
2185+
return rest_ensure_response( $sub_size_data );
21672186
}
21682187

21692188
/**
@@ -2237,6 +2256,35 @@ public function finalize_item( WP_REST_Request $request ) {
22372256
$metadata = array();
22382257
}
22392258

2259+
// Apply all sub-size metadata collected from sideload responses.
2260+
$sub_sizes = $request['sub_sizes'] ?? array();
2261+
2262+
foreach ( $sub_sizes as $sub_size ) {
2263+
$image_size = $sub_size['image_size'];
2264+
2265+
if ( 'original' === $image_size ) {
2266+
$metadata['original_image'] = $sub_size['file'];
2267+
} elseif ( 'scaled' === $image_size ) {
2268+
if ( ! empty( $sub_size['original_image'] ) ) {
2269+
$metadata['original_image'] = $sub_size['original_image'];
2270+
}
2271+
$metadata['width'] = $sub_size['width'] ?? 0;
2272+
$metadata['height'] = $sub_size['height'] ?? 0;
2273+
$metadata['filesize'] = $sub_size['filesize'] ?? 0;
2274+
$metadata['file'] = $sub_size['file'] ?? '';
2275+
} else {
2276+
$metadata['sizes'] = $metadata['sizes'] ?? array();
2277+
2278+
$metadata['sizes'][ $image_size ] = array(
2279+
'width' => $sub_size['width'] ?? 0,
2280+
'height' => $sub_size['height'] ?? 0,
2281+
'file' => $sub_size['file'] ?? '',
2282+
'mime-type' => $sub_size['mime_type'] ?? '',
2283+
'filesize' => $sub_size['filesize'] ?? 0,
2284+
);
2285+
}
2286+
}
2287+
22402288
/** This filter is documented in wp-admin/includes/image.php */
22412289
$metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
22422290

tests/phpunit/tests/rest-api/rest-attachments-controller.php

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3275,16 +3275,32 @@ public function test_sideload_scaled_image() {
32753275

32763276
$this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' );
32773277

3278+
// The sideload endpoint returns lightweight sub-size data; the metadata
3279+
// is written later by the finalize endpoint.
3280+
$sub_size = $response->get_data();
3281+
$this->assertSame( 'scaled', $sub_size['image_size'], 'Response should echo the image_size.' );
3282+
$this->assertSame( wp_basename( $original_file ), $sub_size['original_image'], 'Response original_image should be the basename of the original attached file.' );
3283+
$this->assertGreaterThan( 0, $sub_size['width'], 'Response width should be positive.' );
3284+
$this->assertGreaterThan( 0, $sub_size['height'], 'Response height should be positive.' );
3285+
$this->assertGreaterThan( 0, $sub_size['filesize'], 'Response filesize should be positive.' );
3286+
$this->assertStringContainsString( 'scaled', $sub_size['file'], 'Response file should reference the scaled version.' );
3287+
3288+
// The attached file is still repointed to the scaled version during sideload.
3289+
$new_file = get_attached_file( $attachment_id, true );
3290+
$this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' );
3291+
3292+
// Finalize with the collected sub-size, which writes the metadata.
3293+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3294+
$request->set_param( 'sub_sizes', array( $sub_size ) );
3295+
$response = rest_get_server()->dispatch( $request );
3296+
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
3297+
32783298
$metadata = wp_get_attachment_metadata( $attachment_id );
32793299

32803300
// The original file should now be recorded as original_image.
32813301
$this->assertArrayHasKey( 'original_image', $metadata, 'Metadata should contain original_image.' );
32823302
$this->assertSame( wp_basename( $original_file ), $metadata['original_image'], 'original_image should be the basename of the original attached file.' );
32833303

3284-
// The attached file should now point to the scaled version.
3285-
$new_file = get_attached_file( $attachment_id, true );
3286-
$this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' );
3287-
32883304
// Metadata should have width, height, filesize, and file updated.
32893305
$this->assertArrayHasKey( 'width', $metadata, 'Metadata should contain width.' );
32903306
$this->assertArrayHasKey( 'height', $metadata, 'Metadata should contain height.' );
@@ -3541,4 +3557,55 @@ public function test_finalize_item_invalid_id(): void {
35413557

35423558
$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
35433559
}
3560+
3561+
/**
3562+
* Tests that the finalize endpoint writes regular sub-size metadata
3563+
* collected from sideload responses.
3564+
*
3565+
* @ticket 62243
3566+
* @covers WP_REST_Attachments_Controller::finalize_item
3567+
* @requires function imagejpeg
3568+
*/
3569+
public function test_finalize_writes_regular_sub_sizes(): void {
3570+
$this->enable_client_side_media_processing();
3571+
3572+
wp_set_current_user( self::$author_id );
3573+
3574+
// Create an attachment without generating sub-sizes server-side.
3575+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3576+
$request->set_header( 'Content-Type', 'image/jpeg' );
3577+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3578+
$request->set_param( 'generate_sub_sizes', false );
3579+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3580+
$response = rest_get_server()->dispatch( $request );
3581+
$attachment_id = $response->get_data()['id'];
3582+
3583+
$this->assertSame( 201, $response->get_status() );
3584+
3585+
// Sideload a thumbnail sub-size; the response carries its metadata.
3586+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
3587+
$request->set_header( 'Content-Type', 'image/jpeg' );
3588+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-thumb.jpg' );
3589+
$request->set_param( 'image_size', 'thumbnail' );
3590+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3591+
$response = rest_get_server()->dispatch( $request );
3592+
3593+
$this->assertSame( 200, $response->get_status(), 'Sideloading a thumbnail should succeed.' );
3594+
3595+
$sub_size = $response->get_data();
3596+
$this->assertSame( 'thumbnail', $sub_size['image_size'], 'Response should echo the image_size.' );
3597+
3598+
// Finalize with the collected sub-size, which writes it into metadata.
3599+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3600+
$request->set_param( 'sub_sizes', array( $sub_size ) );
3601+
$response = rest_get_server()->dispatch( $request );
3602+
3603+
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
3604+
3605+
$metadata = wp_get_attachment_metadata( $attachment_id );
3606+
$this->assertArrayHasKey( 'sizes', $metadata, 'Metadata should contain sizes.' );
3607+
$this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'Metadata sizes should contain the sideloaded thumbnail.' );
3608+
$this->assertSame( 'image/jpeg', $metadata['sizes']['thumbnail']['mime-type'], 'Thumbnail mime-type should be recorded.' );
3609+
$this->assertGreaterThan( 0, $metadata['sizes']['thumbnail']['filesize'], 'Thumbnail filesize should be positive.' );
3610+
}
35443611
}

0 commit comments

Comments
 (0)