Problem/Motivation

To quote @deviantintegral:

For Drupal 7, we found that the S3 API was very slow for image styles - on the order of several seconds by the time all of the stat and write calls were done. What I ended up doing was initially serving the image style from a local temp file, and then uploading the file to S3 in a shutdown handler. We should investigate something similar here:

https://github.com/justafish/drupal_amazons3/blob/7.x-2.x/amazons3.modul...

Setting 'cache' => TRUE should help for some of this, but image styles are indeed still slow to generate.

The original issue came up in the flysystem_s3 issue queue, #2528630: Support image style generation.. It was later also reported in the flysystem_gcs issue queue, #3285371: Saving and loading content takes way too long when multiple image styles are used.

Steps to reproduce

Taken from #3285371: Saving and loading content takes way too long when multiple image styles are used:

1. set one image field to have multiple values and GCS as storage. Also, for faster testing, you can disable the required alt field.
2. set an image style or responsive image style for displaying the image field value.
3. upload at least 10 images. (this is the first place where the slowdowns occur)
4. click save.

After clicking save the user is redirected to the canonical node page, so the image styles are generated (probably). The user has to wait until every image is generated to get a response from the server. This takes more than 30 seconds sometimes. If a site has responsive image styles, then this process will run even longer because of the multiple image styles for multiple breakpoints. Also, if someone generates jpg and webp variants (with imageapi_optimize_webp), then again this needed time multiplies until infinity.

Proposed resolution

Don't wait until the remote upload is complete before sending the generated image derivative to the client:

  1. If an non-existent image derivative is requested (any path starting with styles/), a temporary URL is generated to a Flysystem Drupal route. This URL is returned to the browser instead of the direct image URL.
  2. The browser requests this URL to get the image.
  3. A check is done to make sure the source image exists. If it doesn't, a 404 is returned and no further action is taken
  4. If in the meantime the image derivative was created (should be rare), redirect to the remote adapter URL.
  5. If the image still doesn't exist, generate the derivative and store it locally in a temporary folder. The temporary file is now sent to the browser without having to go through the remote adapter. Once the response is sent and the image is displayed in the browser, Drupal will copy the temporary image to the remote adapter.
  6. If the image is requested again and the temporary file still exists, that file is returned, which saves us a trip to the remote adapter.
  7. Once the temporary file is copied to the remote adapter, that URL will be sent to the browser instead of the Flysystem Drupal route.

Remaining tasks

  • Review the proposed implementation
  • Write tests
  • Update the documentation

User interface changes

None.

API changes

None.

Data model changes

None.

Release notes snippet

None.

CommentFileSizeAuthor
#39 support_delivering-2661588-39.patch33 KBThomasDik
#38 stream-util-v1.0.1.patch1.3 KBlhridley
#37 patch-stream-util-library.patch336 byteslhridley
#30 support_delivering-2661588-30.patch33.33 KBtwistor
#30 interdiff.txt4.42 KBtwistor
#29 support_delivering-2661588-29.patch32.5 KBtwistor
#29 interdiff.txt5.57 KBtwistor
#25 support_delivering-2661588-25.patch30.87 KBtwistor
#25 interdiff.txt12.55 KBtwistor
#23 support_delivering-2661588-22.patch31.3 KBtwistor
#23 interdiff.txt9.45 KBtwistor
#22 support_delivering-2661588-22.patch32.2 KBtwistor
#21 support_delivering-2661588-21.patch33.06 KBtwistor
#20 support_delivering-2661588-20.patch32.5 KBjuampynr
#18 interdiff.txt18.17 KBslasher13
#18 2661588-18-image-style-temp.patch48.26 KBslasher13
#17 interdiff.txt4.24 KBslasher13
#17 2661588-17-image-style-temp.patch32.54 KBslasher13
#15 interdiff.txt5.64 KBdeviantintegral
#15 2661588.15-image-style-temp.patch32.54 KBdeviantintegral
#13 interdiff.txt19.69 KBdeviantintegral
#13 2661588.13-image-style-temp.patch32.89 KBdeviantintegral
#10 2661588.10-image-style-temp.interdiff.txt1.09 KBdeviantintegral
#10 2661588.10-image-style-temp.patch28.29 KBdeviantintegral
#9 2661588.9-image-style-temp.interdiff.txt5.39 KBdeviantintegral
#9 2661588.9-image-style-temp.patch28.28 KBdeviantintegral
#6 2661588.6-image-style-temp.interdiff.txt17.93 KBdeviantintegral
#6 2661588.6-image-style-temp.patch27.26 KBdeviantintegral
#5 2661588.5-image-style-temp.interdiff.txt12.56 KBdeviantintegral
#5 2661588.5-image-style-temp.patch21.67 KBdeviantintegral
#4 2661588.4-image-style-temp.patch19.9 KBdeviantintegral

Issue fork flysystem-2661588

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

twistor created an issue. See original summary.

twistor’s picture

Issue summary: View changes
deviantintegral’s picture

Assigned: Unassigned » deviantintegral
deviantintegral’s picture

Status: Active » Needs work
FileSize
19.9 KB

I've got this most of the way locally - TODOs are registering and cleaning up temp files, and copying derivatives from temp to S3 instead of generating the image again.

I filed #2685905: Refactor ImageStyleDownloadController so derivatives can be generated by contrib modules which significantly simplifies our code, as we don't have to re-implement image generation in it's entirety. This patch copies that class with a small addition to add a method added in 8.1.x.

deviantintegral’s picture

Here's an update that:

* Saves temporary files to {file_managed} so cron can clean them up.
* Adds the routes and path processor for temp-then-redirect images.
* Refactors image generation to the controller.
* Adds a bunch of docblock comments.

Still a few TODOs on this, so not quite ready yet.

deviantintegral’s picture

Status: Needs work » Needs review
FileSize
27.26 KB
17.93 KB

This patch:

  • Copies the generated image from temporary:// instead of recreating it for the adapter
  • Splits the shutdown function code to a method
  • Handles temporary files being removed from under Drupal, such as in a server reboot
  • Prevents stampedes with multiple requests that need a temporary file
  • Prevents duplicate uploads of the same image to the adapter in high traffic scenarios
  • Moves the path processor to a new class

I think this is ready for an initial review, though it needs tests. I'm not particularly happy with how large the ImageStyleRedirectController class is, but we're kind of stuck with it if we want to use the upstream controller class for lock and image handling. Any ideas?

deviantintegral’s picture

Oh, and I forgot to mention, this removes the generateImageStyle() method in favour of a generateImageUrl() method. Generating images in serial as URLs are generated is a significant performance issue, and any adapters implementing it should probably be refactored to delay image generation until an inbound request is received.

twang’s picture

  1. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -21,72 +29,102 @@ use Symfony\Component\HttpFoundation\Response;
    +      throw new UploadException(sprintf('Unable to copy %image to %destination', $temporary_uri, $derivative_uri));
    

    May be not a good idea - but is that possible to have a queue here?

  2. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -105,23 +143,132 @@ class ImageStyleRedirectController extends ImageStyleDownloadController {
    +    ob_end_flush();
    

    Does the sequence of calling the two method matter?

  3. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -105,23 +143,132 @@ class ImageStyleRedirectController extends ImageStyleDownloadController {
    +      $t = $this;
    

    Why have to do this?

  4. +++ b/src/PathProcessor/FlysystemImageStyleRedirectProcessor.php
    @@ -0,0 +1,47 @@
    +    if (strpos($path, '/_flysystem-style-redirect/styles') !== 0) {
    

    I saw this string duplicated several times in this method. Assign it to a var may make the code look cleaner.

deviantintegral’s picture

Title: Support delivering local image styles until remote upload is complete. » Support delivering local image styles until remote upload is complete
FileSize
28.28 KB
5.39 KB

Each image upload is a separate HTTP thread, so I don't think a queue will save us anything. We could queue the actual S3 uploads off to a drush background process, but in high traffic scenarios I think it's best to make sure that S3 is populated as soon as possible. I recently discovered imageinfo_cache which is 7.x only but lets you select specific styles to pre-generate. That might be worth investigation (but not as a part of this patch).

The flush call sequence does matter - you have to clear the output buffer before calling flush().

This patch removes the $this assignment for the anonymous function. You need to do that in javascript as the scope of $this changes. PHP 5.4+ allows access to $this so the $t variable isn't needed. However, phpcs is throwing an inspection error on it, but I think that's just a bug in the inspection.

Good call on the string constant - this update adds one.

Also, this fixes not creating directories for image derivatives.

deviantintegral’s picture

This fixes an extra slash in a regex and a missing slash in the path processor, breaking image style routes.

twistor’s picture

Status: Needs review » Needs work
  1. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -0,0 +1,298 @@
    +      drupal_register_shutdown_function(function () use ($source_uri, $temporary_image, $image_style) {
    +        $this->flushCopy($temporary_image, $source_uri, $image_style);
    +      });
    

    I think we want this to happen on the kernel.terminate event. That should save us from having to flush ourselves. If that doesn't work, we should look and see how symfony does the flush. There are some tricks to make it work with fcgi and whatnot. For Apache, I don't think this works quite the way as expected, but I've only tested that when delivering HTML, not images.

  2. +++ b/src/Plugin/ImageStyleGenerationTrait.php
    @@ -16,58 +15,13 @@ use Drupal\image\Entity\ImageStyle;
    -  protected function generateImageStyle($target) {
    

    Can we keep generateImageStyle() and deprecate it for removal before 1.0?

  3. +++ b/src/Routing/FlysystemRoutes.php
    @@ -83,6 +83,19 @@ class FlysystemRoutes implements ContainerInjectionInterface {
    +      // Public image route that serves initially from Drupal, and then
    +      // redirects to a remote URL when it's ready.
    +      $routes['flysystem.' . $scheme . '.style_redirect'] = new Route(
    +        "/_flysystem-style-redirect/styles/{image_style}/$scheme",
    +        [
    +          '_controller' => 'Drupal\flysystem\Controller\ImageStyleRedirectController::deliver',
    +          'scheme' => $scheme,
    +        ],
    +        [
    +          '_access' => 'TRUE',
    +        ]
    +      );
    

    Shouldn't this also be conditional on the image module?

This doesn't seem to address caching, but I'm ok with that. We can do that in a follow up. Thanks!

deviantintegral’s picture

I was all excited about using the terminate event, but I've found two issues:

Any ideas? The existing method is working for me with mod_php and fast cgi on Apache (under an Acquia environment), but we're having trouble with an nginx setup (that I'm testing a fix for now).

deviantintegral’s picture

Category: Task » Feature request
Status: Needs work » Needs review
FileSize
32.89 KB
19.69 KB

After testing this out with mod_php, it looks like responses actually do get flushed with mod_php, even though the docs say otherwise. I filed an issue up at
https://github.com/symfony/symfony-docs/issues/6520

This patch:

  1. Brings back generateImageStyle() and marks it for deprecation.
  2. Makes the route conditional on the image module.
  3. Adds a dedicated class and service for copying images, instead of keeping that code in the controller.
  4. Fixes a strict warning with reset() calls on a function return on PHP 5.5.
twistor’s picture

Status: Needs review » Needs work

This is looking awesome!

Thanks for chasing down the flushing weirdness. I remember this problem before when trying to get poormans cron to run after flush. I also remember running into it when trying to get testbot to pass with fcgi. I'm surprised that it's working for you. I'll have to set up an apache instance to test for myself.

  1. +++ b/composer.json
    @@ -5,6 +5,7 @@
    +    "symfony/filesystem": "~2.8",
    

    Is this needed?

  2. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -0,0 +1,269 @@
    +        throw new \Exception('The temporary image could not be generated');
    

    Can this be a more specific exception, \RuntimeException() would work?

  3. +++ b/src/Controller/ImageStyleRedirectController.php
    @@ -0,0 +1,269 @@
    +    try {
    +      $temporary_image = $this->generateTemporaryImage($scheme, $source_path, $image_style);
    +      // Register a copy task with the kernel terminate handler.
    +      $this->imageStyleCopier->addCopyTask($temporary_image->getFileUri(), $source_uri, $image_style);
    ...
    +      if (!function_exists('fastcgi_finish_request')) {
    +        drupal_register_shutdown_function(function () {
    +          $this->flushCopy();
    +        });
    +      }
    ...
    +      return $this->send($scheme, $temporary_image->getFileUri());
    

    Can this try block be only the minimal amount of code? If send() can throw an exception will it be caught here? Unless that's the whole point, and Drupal would explode if we let the exception leak.

  4. +++ b/src/ImageStyleCopier.php
    @@ -0,0 +1,169 @@
    +  /**
    +   * An array of image derivatives to copy.
    +   *
    +   * @var array
    +   */
    +  protected static $copyTasks = [];
    

    I don't think this needs to be static, since this is a service. Are we worried about container changes?

  5. +++ b/src/Routing/FlysystemRoutes.php
    @@ -83,6 +85,21 @@ class FlysystemRoutes implements ContainerInjectionInterface {
    +      if ($this->moduleHandler->moduleExists('image')) {
    +        $routes['flysystem.' . $scheme . '.style_redirect'] = new Route(
    +          "/_flysystem-style-redirect/styles/{image_style}/$scheme",
    

    This gets skipped for local images, correct? Because of path processing order.

deviantintegral’s picture

Status: Needs work » Needs review
FileSize
32.54 KB
5.64 KB

Is this needed [symfony/filesystem]?

Ah! There's a FileNotFoundException both in HttpFoundation and in Filesystem. That explains a good bit of confusion I had. I think since it's all thrown in the controller I'll move it all to the HttpFoundation exception.

throw new \Exception('The temporary image could not be generated');

Agreed, fixing.

Also cleaned up the try / catch block to a single line in the try.

+ protected static $copyTasks = [];

Good call - fixed in this patch.

This gets skipped for local images, correct? Because of path processing order.

Yes, that was intentional to not expose this code to local images. I didn't see any value for the local adapter where a redirect would lead back to yourself. Is there something else here?

juampynr’s picture

+++ b/src/Controller/ImageStyleDownloadController.php
@@ -0,0 +1,297 @@
+ * Contains \Drupal\image\Controller\ImageStyleDownloadController.

flysystem, not image. However, @file docblocks are not needed any more in classes, interfaces and traits.

slasher13’s picture

slasher13’s picture

twistor’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests

@slasher13, thanks for the re-roll. #16 wasn't about removing all of the @file references, just the ones introduced by the patch. Can we remove those changes? It's adding 16kb of noise to the patch.

juampynr’s picture

Assigned: deviantintegral » Unassigned
Status: Needs work » Needs review
FileSize
32.5 KB

Removed unrelated @file removals from the patch.

twistor’s picture

oops. Had some other crap in there.

twistor’s picture

Did some more reviewing. Mostly minor cleanups/changes.

Do we have a testing strategy planned?

twistor’s picture

+++ b/src/ImageStyleCopier.php
@@ -128,9 +122,6 @@ class ImageStyleCopier implements EventSubscriberInterface, ContainerInjectionIn
-   *
-   * @return string Thrown if the image could not be copied.
-   *   Thrown if the image could not be copied.
    */
   protected function copyToAdapter($temporary_uri, $source_uri, ImageStyleInterface $image_style) {

I removed the return value and the thrown exception from copyToAdapter. It seems to me that if one image fails, it still makes sense to try the next one. At least in the case of a network error.

twistor’s picture

Welp. I've run into a hell of a bug. At certain times the redirect is cached incorrectly. This causes old images to be served. I spent quite a while debugging.

I think the path modification in FlysystemImageStyleRedirectProcessor is causing the problem. The dynamic page cache is serving a cached response based on the route. This is problematic since the route only has image_style and scheme after FlysystemImageStyleRedirectProcessor runs.

I dug around in the context/render caching system, but haven't solved it yet.

For now, I'm just disabling caching on the redirect.

I've removed the temp file saving. I'm not sure what that's for. If we're worried about stale files, wouldn't it be easier to just remove them on cron? Also, what about deleting the temp files after the copy?

deviantintegral’s picture

Do we have a testing strategy planned?

I've been struggling with this one too. Mocking all of the HTTP calls is going to be pretty complex. While this is really for remote adapters, realistically it should work for the local adapter too. Perhaps that's how we can do functional tests for this?

For now, I'm just disabling caching on the redirect.

Oy! That is nasty. I'm OK with disabling the cache for now, and fixing that as a followup if the performance side matters.

I've removed the temp file saving. I'm not sure what that's for. If we're worried about stale files, wouldn't it be easier to just remove them on cron? Also, what about deleting the temp files after the copy?

I don't think we want to delete files after the copy, because other threads could be in progress caught between the file_exists() call and actually returning the file. We could manually add a cron worker to clean up old files, but isn't that the point of registering a file as temporary anyways?

twistor’s picture

I don't think we want to delete files after the copy, because other threads could be in progress caught between the file_exists() call and actually returning the file.

Sounds like we need a lock.

I suppose it's a case of familiarity breeds contempt. I honestly despise Drupal's database handling of temp files. My main problem is hammering the db 4 times-per-second per-thread.

Also, there was a race condition in your last patch that removing Drupal temp storage eliminates.

+++ b/src/Controller/ImageStyleRedirectController.php
@@ -0,0 +1,267 @@
+    // Try to generate the temporary image, watching for other threads that may
+    // also be trying to generate the temporary image.
+    try {
+      $success = $this->generate($image_style, $image_uri, $destination_temp);
+      if (!$success) {
+        throw new \RuntimeException('The temporary image could not be generated');
+      }
+      $temporary_image->save();
+    }
mcrittenden’s picture

(Closed #2805037: Stale image styles served from temporary:// under some circumstances and moved the info here)

We're using the patch from #25 and having an issue that seems to be related with it. Basically, it seems like stale image styles can be served from temporary:// under some circumstances.

To reproduce:

0. Use S3 (not sure if this is necessary to reproduce or not)
1. Update an image style
2. View an image of that image style
3. Change the image style again
4. View the image again - you will see the stale image from #2 if you did it quickly

Looks like the issue is that editing an image style DOES correctly flush those images from S3, but it DOES NOT clear them out from temporary://, which is where Flysystem put them before they got sent to S3 in the first place. So what happens is that in ImageStyleRedirectController.php, it sees that the image doesn't exist on S3, so it goes to deliveryTemporary() which itself goes to generateTemporaryImage(), which finally calls generate().

In that function, you see this line:

$success = file_exists($derivative_uri) || $image_style->createDerivative($image_uri, $derivative_uri);

Note that $derivative_url here is the URL for the file in temporary://, which does still exist if you did it too quickly for the OS to clear out your /tmp directory. Thus, you get a stale image. I've worked around this locally like so:

function MODULENAME_file_update(Drupal\Core\Entity\EntityInterface $entity) {
  if ($entity->bundle() !== 'image') {
    return;
  }

  $filename = $entity->getFilename();
  $styles = ImageStyle::loadMultiple();
  foreach ($styles as $style) {
    $temp_path = 'temporary://styles/' . $style->id(). '/temporary/flysystem/s3/' . $filename;
    file_unmanaged_delete($temp_path);
  }
}
twistor’s picture

I've managed to hack around the cached redirect, I'm not sure if it's correct, but it's working. The hack is to add a hash of the filepath back on the URL, so that Drupal realizes this is a different URL.

#28 should be fixed as well, but I'm not excited about it. As was stated in #26, we don't want to delete the file, since another thread could think it still exists. What we're doing here is reading the file into memory, if it exists, and serving it that way. That seems to work fine, we loose some optimizations regarding X-Sendfile. The problem is that it's possible threads could generate the image style multiple times.

twistor’s picture

This should fix the caching issue.

twistor’s picture

DamienMcKenna’s picture

Random crazy idea - how about creating items in the queue and then add an AJAX callback to trigger the queue on the next page request? That way the items go into a queue so e.g. an S3 outage / slowdown wouldn't break the save process, but the callback would trigger the file(s) to be pushed up so there wouldn't be this delay until the next cron cycle.

Dave Reid’s picture

I've been having an issue with this patch with an S3 private bucket. The image derivatives display once correctly with the redirect path, and then once the derivative is saved to S3, when it attempts to generate a URL, I keep getting the 'itok' query string appended to the URL, and Amazon rejects the signed URL because of the additional query string data in the URL. If I remove the itok query, then the generated URL works. I wonder if we should just always route through the redirect path instead. I wonder if other providers would have the need to strip the itok value from the URL.

twistor’s picture

I haven't been around lately, but wanted to mention an idea I found that might work as a stopgap for S3.

https://github.com/thephpleague/glide/issues/170#issuecomment-272943966

twistor’s picture

Also, if anyone in this thread wants commit access, I'd be happy for the help.

MiSc’s picture

Status: Needs review » Needs work
lhridley’s picture

Thought I'd chime in on this, as I've been going round and round on the issue of successfully applying image styles and storing derivative images in object storage successfully (we are using GCS for object storage, and using Flysystem and Flysystem_s3 to interact with GCS).

To get a solution that successfully writes images to the object store via the streamwrappers provided by the above two modules AND also write the derivative images (thumbnails and other images generated with image styles), we finally had to resort to patching a dependent library.

There is an outstanding PR on github for twistor/stream-util (https://github.com/twistor/stream-util) which is a dependency for this module. By patching this library for the outstanding PR on the project (https://github.com/twistor/stream-util/pull/1), we were able to resolve the issues we were having generating and writing derivative images to the remote object storage, and subsequently retrieving and displaying those images with Drupal.

Because the patch is explicit for version v1.0.1 of twistor/stream-util, I also explicitely declared that version as a dependency in compser.json, pinning the project to this partcular version. Hopefully the outstanding PR will get merged soon, in the meantime this did resolve the issue I was having on two different projects with derivative images.

Patch is attached, please note this is NOT for this module, but for the twistor/stream-util library.

lhridley’s picture

FileSize
1.3 KB

Adding an updated patch to replace the one from #37

ThomasDik’s picture

Added patch for 2.x beta version.

DieterHolvoet made their first commit to this issue’s fork.

DieterHolvoet’s picture

Issue summary: View changes
DieterHolvoet’s picture

I created a MR based on the last patch. I had to fix a bunch of stuff to get it working for me:

  • Update deprecated/removed core services and functions
  • Fix some small issues that came up
  • Fix the temporary image generation failing if an image style changes the extension of the generated derivative

I also opened an MR in the flysystem_gcs project, integrating the new code here in an actual adapter: #3285371: Saving and loading content takes way too long when multiple image styles are used.

Now both MR's are in a working state, this one still needs tests.

lhridley’s picture

Assigned: Unassigned » lhridley
lhridley’s picture

@dieterholvoet Would you be kind enough to change your MR to apply against the 2.1.x-dev branch? I'd like to see if the automated tests will pass, it appears that your MR does apply to 2.1.x as well as 8.x-1.x.

lhridley’s picture

Assigned: lhridley » Unassigned
lhridley’s picture

Version: 8.x-1.x-dev » 2.1.x-dev
lhridley’s picture

Status: Needs work » Needs review
DieterHolvoet’s picture

Status: Needs review » Needs work

I rebased the branch against 2.1.x, there weren't any conflicts. Changing back to Needs work because there aren't any tests yet - unless you decide that's not necessary.

lhridley’s picture

https://www.drupal.org/pift-ci-job/2730529

Existing test coverage appears to be adequate, however this patch does fail one existing tests. In looking at the test it appears that perhaps the test needs to be updated.

Can you take a look at the failing test, determine if the failing assertion on `src/Unit/Routing/FlysystemRoutesTest.php:161` needs to be revised, and submit a revision as part of this MR?

If the assertion is still valid, note that here as well, and we'll need to work on this a bit more.