Problem/motivation

When processing items in a queue, sometimes it is useful to delay further processing of an item due to uncontrollable external circumstances. Unfortunately, this isn't possible with the current queue worker processing via cron since RequeueException immediately re-queues an item (which causes an infinite loop depending on queue implementation) and all other exceptions trigger a log message to be stored via watchdog.

Proposed resolution

I propose introducing a new exception, DelayedRequeueException to behave similarly to RequeueException except that the item being processed is not released via QueueInterface::releaseItem(). This would allow for an in-built back-off delay in the further processing of queue items without requiring a custom queue implementation.

Backward compatibility

No backward-incompatible changes should be required as the only change to Drupal will be to add a new exception. If a queue runner is compatible with Cron::processQueue, then this will cause no problems as the only change between throwing \Exception and the new exception is the lack of logging. For the same reason, no new functionality needs to be added to existing queue backends.

CommentFileSizeAuthor
#78 3116478-78--9.0.x.patch23.47 KBbr0ken
#69 3116478-69.patch23.45 KBclayfreeman
#69 3116478-69.interdiff.txt299 bytesclayfreeman
#68 interdiff-63-68.txt299 bytesjungle
#68 3116478-68.patch0 bytesjungle
#63 3116478-63.patch23.16 KBclayfreeman
#63 3116478-63.interdiff.txt9.08 KBclayfreeman
#61 3116478-61.patch21.43 KBclayfreeman
#61 3116478-61.interdiff.txt25.74 KBclayfreeman
#59 3116478-59.patch28.52 KBclayfreeman
#59 3116478-59.interdiff.txt1.41 KBclayfreeman
#56 3116478-56.patch28.52 KBclayfreeman
#56 3116478-56.interdiff.txt7.87 KBclayfreeman
#52 3116478-52.patch29.52 KBclayfreeman
#52 3116478-52.interdiff.txt590 bytesclayfreeman
#50 3116478-50.patch29.47 KBclayfreeman
#50 3116478-50.interdiff-43.txt11.51 KBclayfreeman
#48 3116478-48.patch34.35 KBclayfreeman
#48 3116478-48.interdiff.txt16.8 KBclayfreeman
#43 interdiff-3116478-40-43.txt1.01 KBmrinalini9
#43 3116478-43.patch20.01 KBmrinalini9
#39 3116478-39.patch20.03 KBclayfreeman
#39 3116478-39.interdiff.txt1.41 KBclayfreeman
#35 3116478-35.interdiff.txt412 bytesclayfreeman
#35 3116478-35.patch19.68 KBclayfreeman
#27 3116478-27.interdiff.txt10.73 KBclayfreeman
#27 3116478-27.patch19.47 KBclayfreeman
#24 3116478-24.interdiff.txt14.28 KBclayfreeman
#24 3116478-24.patch16.71 KBclayfreeman
#23 interdiff-20-23.txt2.91 KBjungle
#23 3116478-23.patch14.47 KBjungle
#16 3116478-16.interdiff.txt5.79 KBclayfreeman
#16 3116478-16.patch11.73 KBclayfreeman
#7 3116478-7.interdiff.txt7.73 KBclayfreeman
#7 3116478-7.patch9.25 KBclayfreeman
#2 3116478-2.patch1.4 KBclayfreeman
#13 3116478-13.interdiff.txt2.16 KBclayfreeman
#13 3116478-13.patch9.17 KBclayfreeman
#20 3116478-20.interdiff.txt3.97 KBclayfreeman
#20 3116478-20.patch14.7 KBclayfreeman
#25 3116478-25.interdiff.txt2.64 KBclayfreeman
#25 3116478-25.patch16.48 KBclayfreeman
#31 3116478-31.interdiff.txt1.31 KBclayfreeman
#31 3116478-31.patch19.67 KBclayfreeman
#38 3116478-38.interdiff.txt4.18 KBclayfreeman
#38 3116478-38.patch20.05 KBclayfreeman
#40 3116478-40.interdiff-35-40.txt4.79 KBclayfreeman
#40 3116478-40.patch20.03 KBclayfreeman

Comments

clayfreeman created an issue. See original summary.

clayfreeman’s picture

Status: Active » Needs review
StatusFileSize
new1.4 KB

Adding a patch to implement this feature request.

clayfreeman’s picture

Assigned: clayfreeman » Unassigned
cliddell’s picture

Status: Needs review » Reviewed & tested by the community

Nice. Can't find any need for improvement/nitpick and appears to work as intended. Sending to RTBC.

clayfreeman’s picture

Status: Reviewed & tested by the community » Needs work

Setting to "Needs Work" until I have a chance to add a test as per the suggestion of @chx.

ghost of drupal past’s picture

Issue summary: View changes
clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new9.25 KB
new7.73 KB

Added a test case for queue item processing to assert expected logging and queue characteristics.

ghost of drupal past’s picture

Status: Needs review » Reviewed & tested by the community

This is fantastic, thanks!

jungle’s picture

+++ b/core/tests/Drupal/Tests/Core/CronTest.php
@@ -0,0 +1,197 @@
+      $module_handler       = $this->createMock('Drupal\Core\Extension\ModuleHandlerInterface');
+      $lock_backend         = $this->createMock('Drupal\Core\Lock\LockBackendInterface');
+      $queue_factory        = $this->createMock('Drupal\Core\Queue\QueueFactory');
+      $state                = $this->createMock('Drupal\Core\State\StateInterface');
+      $account_switcher     = $this->createMock('Drupal\Core\Session\AccountSwitcherInterface');
+      $logger               = $this->createMock('Psr\Log\LoggerInterface');
+      $queue_worker_manager = $this->createMock('Drupal\Core\Queue\QueueWorkerManagerInterface');
+      $time                 = $this->createMock('Drupal\Component\Datetime\TimeInterface');
...
+        ->with('Exception')

Not sure if this kind of indentation is encouraged

jungle’s picture

Status: Reviewed & tested by the community » Needs work
Related issues: +#2937853: Fix 'Drupal.Formatting.MultipleStatementAlignment' coding standard

Found the corresponding coding standard is Drupal.Formatting.MultipleStatementAlignment.NotSame, #2937853: Fix 'Drupal.Formatting.MultipleStatementAlignment' coding standard

I'd set back to NW, besides of this, RTBC +1

jungle’s picture

Should be Drupal.WhiteSpace.OperatorSpacing.NoSpaceBefore probably. But phpcs says that it does not exist

jungle’s picture

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new9.17 KB
new2.16 KB

Thanks for the feedback @Charlie ChX Negyesi and @jungle!

I've attached an updated patch to remove extraneous whitespace. Let me know if I should change anything else.

jungle’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @clayfreeman

+++ b/core/tests/Drupal/Tests/Core/CronTest.php
@@ -0,0 +1,197 @@
+      $module_handler = $this->createMock('Drupal\Core\Extension\ModuleHandlerInterface');
+      $lock_backend = $this->createMock('Drupal\Core\Lock\LockBackendInterface');
+      $queue_factory = $this->createMock('Drupal\Core\Queue\QueueFactory');
+      $state = $this->createMock('Drupal\Core\State\StateInterface');

Just FYI, personally, I prefer using prophecy. See https://www.drupal.org/docs/8/phpunit/using-prophecy

longwave’s picture

This is a feature request and so I think it can only go into 9.1.x now.

Do we also need a kernel test, ie. extend or duplicate Drupal\Tests\system\Kernel\System\CronQueueTest?

+++ b/core/tests/Drupal/Tests/Core/CronTest.php
@@ -0,0 +1,197 @@
+      $this->assertNotEquals(TRUE, \Drupal::state()->get('cron_test.watchdog_exception_called'));
...
+      $this->assertEquals(TRUE, \Drupal::state()->get('cron_test.watchdog_exception_called'));

Is it neater to use assertFalse/assertTrue over assertEquals here?

clayfreeman’s picture

StatusFileSize
new11.73 KB
new5.79 KB

I'm not too familiar with the semantics for feature requests, but since this is a fairly small non-BC breaking change shouldn't it be acceptable for release under the guidelines (especially since there was no 8.9 alpha)?

The primary reason that my organization is looking forward to this patch hitting 8.9 is because we're going to have a substantially harder time upgrading to 9.x due to the size of our code base. While not preferred, if this issue must target 9.x I can appreciate that.

Attached is an updated patch to add some testing to \Drupal\Tests\system\Kernel\System\CronQueueTest and use UnitTestCase::assertTrue() / UnitTestCase::assertFalse().

clayfreeman’s picture

Status: Reviewed & tested by the community » Needs review
longwave’s picture

Status: Needs review » Reviewed & tested by the community

I guess it is a minor addition with no disruption so this is perhaps OK for 8.9.x after all - let's leave that to the core committers to decide.

The changes look great, thanks for adding to CronQueueTest to prove that the delayed item works end to end, so marking this RTBC.

neclimdul’s picture

Status: Reviewed & tested by the community » Needs review

So I started thinking through if this gave other queue systems the tools to support this behavior and just catching an exception and continuing starts to seem kinda weird and hacky. You have no idea what the delay is going to be and its just what ever the claiming code sent as the lease for running the queue and now we're going to delay out the rest of the time. :( Shouldn't we be sending a delay? Which leads to should there be some sort of delayItem($item, $delay) method that delay's the item allowing say beanstalk to send an explicit delay call?

Maybe I'm wrong, the code is very simple, but I'm not sure this is the right approach.

clayfreeman’s picture

StatusFileSize
new14.7 KB
new3.97 KB

Here's a supplemental conversation on Slack for the record.

This patch adds support for a custom delay interval set in DelayedRequeueException that is intended to be used for updating the item's lock expiry timestamp to the current time plus the specified interval (in seconds). Custom delays are only supported for queues that implement the new DelayableQueueInterface.

I'm not sure that this feature is immediately applicable to \Drupal\Core\Cron::processQueues(), so I'm not going to spend time writing tests for it yet; in the linked discussion, it was decided that this would be useful primarily for persistent queue processors but cron is definitely not that.

@neclimdul let me know if this patch addresses your concern. I personally would opt to implement this functionality at a later date since it can be done without breaking BC and presently adds way more complexity than this issue originally called for without any gain due to the current apparent lack of support for persistent queue processors.

jungle’s picture

Thank you, @clayfreeman!

  1. +++ b/core/modules/system/tests/modules/cron_queue_test/src/Plugin/QueueWorker/CronQueueTestException.php
    @@ -22,9 +23,13 @@ public function processItem($data) {
    -    else {
    +    elseif ($state->get('cron_queue_test_exception') === 1) {
           $state->set('cron_queue_test_exception', 2);
    ...
    +    elseif ($state->get('cron_queue_test_exception') === 2) {
    +      $state->set('cron_queue_test_exception', 3);
    

    Prefer replacing else-if with if to both two usages, The logic do not change if to do so.

  2. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,197 @@
    +      $cron = new \ReflectionClass(Cron::class);
    +      $processQueues = $cron->getMethod('processQueues');
    +      $processQueues->setAccessible(TRUE);
    ...
    +      $processQueues->invoke($this->cron);
    ...
    +      $processQueues->invoke($this->cron);
    ...
    +      $processQueues->invoke($this->cron);
    ...
    +      $processQueues->invoke($this->cron);
    ...
    +      $processQueues->invoke($this->cron);
    

    It's tricky here, Cron::processQueues() is a protected method. Repalace with $this->cron->run(); the test should pass as well. $this->cron->run() does call Cron::processQueues()

  3. +++ b/core/lib/Drupal/Core/Queue/DelayedRequeueException.php
    @@ -0,0 +1,60 @@
    +   * The interval of time that the item should remain locked (in seconds).
    ...
    +   * If this value is positive, the queue should discard the current lock expiry
    +   * time and update it by adding the specified interval to the current time.
    ...
    +   * @var int|float
    ...
    +  protected $delay = 0;
    

    Do not understand why to allow float. delay 0.5 sec? Make a little sense, if so, why not just use microseconds.

  4. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,197 @@
    +    protected function setUp() {
    +      parent::setUp();
    ...
    +      $this->cron = new Cron($module_handler, $lock_backend, $queue_factory, $state, $account_switcher, $logger, $queue_worker_manager, $time);
    

    The ::setup() is about 70 lines long. not sure if using prophecy can make it better.

  5. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,197 @@
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertTrue(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertFalse(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    ...
    +      $this->assertTrue(\Drupal::state()->get('cron_test.watchdog_exception_called'));
    

    \Drupal::state() are called many times, Set it a property in ::setup()?

  6. +++ b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php
    @@ -78,6 +78,24 @@ public function testExceptions() {
    +    $this->assertEqual(\Drupal::state()->get('cron_queue_test_exception'), 3);
    ...
    +    // The item should be left in the queue with delayed release.
    +    $this->assertEqual($queue->numberOfItems(), 1, 'Item is still in the queue.');
    

    Better to use $this->assertEquals()

  7. Is a change record necessary? To tell developers how to use this.
clayfreeman’s picture

Thanks for the additional review @jungle. Here are my responses:

  1. OK. Done.
  2. If I were to do this, it would require a lot more complexity in ::setUp() since I would have to fully mock services that \Drupal\Core\Cron::run() uses but \Drupal\Core\Cron::processQueues() never uses. There are several examples of reflection being used in other tests; is there a reason why we should do the extra work to switch which method is being called here?
  3. Float (in seconds) is preferred in my opinion because you can still reach microsecond precision and achieve a higher range of values than an integer in the same byte length. One strong advantage is that you can use seconds as your base unit which requires little thought by the queue developer. Accuracy would be the weak point if any. Let me know what you think. (side note: PHP also uses float for microtime(TRUE))
  4. I can try to refactor to use Prophecy. Done.
  5. OK. Done.
  6. OK. Done.
  7. I'd imagine that one would be needed if we opt to implement support for a custom delay in this issue, but we won't be able to do that until we've reached a point where we're happy with the interface.

Do you think it is necessary to add the extra functionality from #19 to \Drupal\Core\Cron::processQueues()? Or should we remove it since it won't necessarily be accurate?

Also, do you think that it is worth exploring support for a custom delay at this time or should that be postponed until later? I'm still unclear about what the immediate value-add would be if we implement this now.

jungle’s picture

StatusFileSize
new14.47 KB
new2.91 KB

Thanks for your quick response. @clayfreeman

  1. #22.2. I tried simply replacing them, do not need to touch the setup(). see the interdiff. The decision is yours. I do not have a good reason for both. :)
  2. #22.3 make sense.
  3. #22.4 👍
  4. #22.7 👍
  5. I'd suggest allowing to set delay in QueueWorker annotation. For example
    /**
     * 
     *
     * @QueueWorker(
     *   id = "foo_worker",
     *   title = @Translation("Foo worker"),
     *   cron = {"time" = 60},
     *   delay = {"time" = 0}
     * )
     */
    

    Default to 0 -- no delay. So it won't break existed queue workers.

clayfreeman’s picture

StatusFileSize
new16.71 KB
new14.28 KB

Uploading a patch containing my progress from #22. Haven't reviewed your patch yet.

clayfreeman’s picture

StatusFileSize
new16.48 KB
new2.64 KB

Uploading new patch to adopt changes in #23. Thanks @jungle!

For some reason during initial development cron wouldn't run the queues for me, but it's working now and your suggestion is better.

neclimdul’s picture

Status: Needs review » Needs work
  1. +++ b/core/lib/Drupal/Core/Queue/DelayableQueueInterface.php
    @@ -0,0 +1,32 @@
    +  public function releaseItem($item, $delay = 0);
    +
    

    Yeah, I actually agree with your BC note in context of this method signature because this change could be a problem. I didn't notice that's what you put in slack.

    The BC concern is why I suggested something like public function delayItem($item, $delay) in my earlier review because it is an entirely new method.. Its also a bit clearer, and I'm realizing now possibly allow you to _add_ an item with a delay which is handy.

  2. +++ b/core/modules/system/tests/modules/cron_queue_test/src/Plugin/QueueWorker/CronQueueTestException.php
    @@ -22,9 +25,13 @@ public function processItem($data) {
    -    else {
    +    if ($state->get('cron_queue_test_exception') === 1) {
           $state->set('cron_queue_test_exception', 2);
         }
    +    if ($state->get('cron_queue_test_exception') === 2) {
    +      $state->set('cron_queue_test_exception', 3);
    +      throw new DelayedRequeueException();
    +    }
    

    Totally a nit not relevant to the issue but these magic numbers are kinda hard to follow. Maybe we could make them constants on the test to tie everything together and self document what the states are?

    Also, this doesn't test the delay time. :(

  3. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,182 @@
    +      $queue_worker_plugin = $this->prophesize('Drupal\Core\Queue\QueueWorkerInterface');
    +      $queue_worker_plugin->processItem('Complete')->willReturn();
    +      $queue_worker_plugin->processItem('DelayedRequeueException')->willThrow(DelayedRequeueException::class);
    +      $queue_worker_plugin->processItem('Exception')->willThrow(\Exception::class);
    +      $queue_worker_plugin->processItem('SuspendQueueException')->willThrow(SuspendQueueException::class);
    +      $queue_worker_plugin->processItem('RequeueException')->will(function ($args, $mock, $method) {
    

    oh this is a lot easier to read, nice work! I'm also a big fan of prophecy but I didn't want to say anything because a test is a test and I'm not writing it. :-D

  4. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,182 @@
    +  if (!function_exists('watchdog_exception')) {
    +
    +    /**
    +     * Fake watchdog_exception() function used to throw an exception.
    +     */
    +    function watchdog_exception($type, \Exception $exception, ...$args) {
    +      \Drupal::state()->set('cron_test.watchdog_exception_called', TRUE);
    +    }
    +
    +  }
    

    This made me uncomfortable the first time I read it and I wasn't sure why but now thinking about it I realize the problem. It is sort of a ticking time bomb because if another test defines the method tests might pass or fail depending on the order the tests run in. I don't have a suggestion of the top of my head but yeah.

    I guess a real fix might be blocked on #2932518: Deprecate watchdog_exception which is no reason to hold this up. Can you just add @runInSeparateProcess to the test, that might avoid the problem for now. :(

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new19.47 KB
new10.73 KB
  1. I've altered the method from ::releaseItem($item, $delay = 0) to ::delayItem($item, $delay) so that we don't have BC concerns.
  2. I've updated CronQueueTestException to use class constants for its state values. These are used by CronQueueTest now as well.
  3. CronQueueTest should now test for a value set by DatabaseQueue::delayItem() that is larger than the original lease.
  4. I've updated CronTest to add @runInSeparateProcess.
  5. I reverted the change suggested in #21.1 since it didn't end up being logically identical and added a note to prevent the same mistake from happening again. This should fix the most recent test failure.
neslee canil pinto’s picture

Status: Needs review » Reviewed & tested by the community

@clayfreeman, #27 Looks good to me 👍🏻, Moving it to RTBC, Thanks.

jungle’s picture

Status: Reviewed & tested by the community » Needs review

Hi @Neslee Canil Pinto. From what I got from the conversations on slack between @neclimdul and @clayfreeman, @neclimdul will check back soon, who is the maintainer of Queue/QueueAPI. His opinions are important. So let's set it back to Needs Review.

neclimdul’s picture

Status: Needs review » Needs work

Thanks everyone, I've got a few observations that should probably be noted for committers and i'm sorry, a nit that will make this easier to use.

  1. +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -171,6 +171,31 @@ public function releaseItem($item) {
    +      $expire = time() + intval($delay);
    +      if ($expire < 0) {
    +        $expire = 0;
    +      }
    

    Observation, boy howdy you have to work hard to get a negative value to trigger this :) Probably could have just tested the delay since using a negative delay to set a time in the past doesn't really do anything I don't think but its all good.

  2. +++ b/core/modules/system/tests/modules/cron_queue_test/src/Plugin/QueueWorker/CronQueueTestException.php
    @@ -13,17 +16,36 @@
    +      throw (new DelayedRequeueException())->setDelay($delay);
    

    nit, could we just take the delay as a constructor argument to make this more straight forward?

  3. +++ b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php
    @@ -91,21 +119,21 @@ public function testExceptions() {
    -    $this->assertEqual($queue->numberOfItems(), 2, 'Failing queue stopped processing at the failing item.');
    +    $this->assertEquals(2, $queue->numberOfItems(), 'Failing queue stopped processing at the failing item.');
    ...
    -    $this->assertEqual($item->data, 'crash', 'Failing item remains in the queue.');
    +    $this->assertEquals('crash', $item->data, 'Failing item remains in the queue.');
         $item = $queue->claimItem();
    -    $this->assertEqual($item->data, 'ignored', 'Item beyond the failing item remains in the queue.');
    +    $this->assertEquals('ignored', $item->data, 'Item beyond the failing item remains in the queue.');
    

    good catch. not related but we'll keep it a secret between you me and all the people that read this comment ;)

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new19.67 KB
new1.31 KB
  1. This was more to do with the value constraints of the storage mechanism than the input value.
  2. Done. Wasn't sure if constructor compatibility would be a concern which is why I used a method to begin with.
  3. Figured this could have some improved semantics. :)
neclimdul’s picture

Status: Needs review » Reviewed & tested by the community

hm... thought i'd kicked this up. I think this is looking very good, lets see what committers think.

jungle’s picture

Issue tags: +Needs change record

I think a CR is necessary.

clayfreeman’s picture

Issue tags: -Needs change record

CR created here.

clayfreeman’s picture

Version: 8.9.x-dev » 9.1.x-dev
StatusFileSize
new412 bytes
new19.68 KB

Updating patch to address a nitpick of the new test case's ::setUp() method return type. Leaving in RTBC since this is a rather small change that shouldn't need additional review.

Also changing target to 9.1.x since we're in a beta feature freeze. Still, I understand that core committers have some leeway in what makes it in during a beta freeze (especially if the change is rather minor); if this issue makes it into 8.9.x, my team would be much obliged!

clayfreeman’s picture

I should note that the change in #35 is only backward compatible with Drupal versions where the minimum PHP version is at least 7.1; the patch in #31 should be used where this is not the case.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

Here's some thoughts about the runtime code. I've not reviewed the tests yet.

  1. +++ b/core/lib/Drupal/Core/Cron.php
    @@ -180,6 +182,14 @@ protected function processQueues() {
    +            // The worker requested the task not be immediately requeued.
    +            if ($queue instanceof DelayableQueueInterface) {
    +              // This queue can handle a custom delay; use the duration provided
    +              // by the exception.
    +              $queue->delayItem($item, $e->getDelay());
    +            }
    

    What happens if $queue is not a DelayableQueueInterface and is that tested?

  2. +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -171,6 +171,31 @@ public function releaseItem($item) {
    +      $expire = time() + intval($delay);
    

    Why the call to intval() - now we have PHP 7.3 we can typehint to integer.

  3. +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -171,6 +171,31 @@ public function releaseItem($item) {
    +      if ($expire < 0) {
    +        $expire = 0;
    +      }
    

    I ponder if you mean here that $delay should always be positive. Imo we should throw an exception if delay is negative.

  4. +++ b/core/lib/Drupal/Core/Queue/DelayedRequeueException.php
    @@ -0,0 +1,63 @@
    +  public function __construct($delay = 0) {
    

    Let's typehint $delay to integer.

  5. +++ b/core/lib/Drupal/Core/Queue/DelayedRequeueException.php
    @@ -0,0 +1,63 @@
    +  /**
    +   * Set the desired delay interval for this item.
    +   *
    +   * @param int|float $delay
    +   *   The desired delay interval for this item.
    +   *
    +   * @see self::$delay
    +   *   For a value description of the $delay parameter.
    +   *
    +   * @return static
    +   *   The object itself for chaining.
    +   */
    +  public function setDelay($delay) {
    +    if (is_numeric($delay) && $delay >= 0) {
    +      $this->delay = $delay;
    +    }
    

    Let's not have a setter that gives this exception state that's unnecessary. Do the setting in the constructor.

The new functionality definitely looks useful.

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new20.05 KB
new4.18 KB

Here's a link to a discussion on Slack about whether or not we should abandon sub-second precision in the delays by type-hinting to int in ::delayItem() (and related code). We decided that we don't really see a value in sub-second precision, so support for that is being removed in this patch to address the review criteria in #37.

Here's my feedback on the review:

  1. I've added some additional comments here to make each case's expected behavior a bit more clear. This is being tested (albeit tangentially through deduction) in CronTest.php on line 147. I was hesitant to include a test for the actual expiry value since the chance of unexpected temporal drift in the test runners is non-zero and I can't think of a way to easily control for that; I'd much rather have a reliable test than one that fails occasionally for seemingly no reason.
  2. I reworked this to ensure that ::delayItem() is using a typehint of int and throw an exception if a negative value is encountered.
  3. Done; see above.
  4. Done.
  5. Done.

Thanks for your review @alexpott! This has been a fun one to write and I look forward to having my first contribution to Drupal under my belt :)

clayfreeman’s picture

StatusFileSize
new20.03 KB
new1.41 KB

Just realized that I missed a couple types in the documentation.

clayfreeman’s picture

StatusFileSize
new20.03 KB
new4.79 KB

Third time's the charm... posting an interdiff all the way back from 35 to make review easier; kept noticing other things that needed updating.

jungle’s picture

Status: Needs review » Needs work

Thank you @clayfreeman! All points in #37 are addressed.

+++ b/core/lib/Drupal/Core/Cron.php
@@ -180,6 +182,20 @@ protected function processQueues() {
+          catch (DelayedRequeueException $e) {
+            // The worker requested the task not be immediately requeued.
+            //
+            // If the queue doesn't support ::delayItem(), we should leave the
+            // item's current expiry time alone.
+            //
+            // If the queue does support ::delayItem(), we should allow the
+            // queue to update the item's expiry using the requested delay.
+            if ($queue instanceof DelayableQueueInterface) {
+              // This queue can handle a custom delay; use the duration provided
+              // by the exception.
+              $queue->delayItem($item, $e->getDelay());
+            }

But the comment to address #37.1, I would suggest changing it to the following:

          catch (DelayedRequeueException $e) {
            // The worker requested the task not be immediately re-queued.
            // - If the queue doesn't support ::delayItem(), we should leave the
            // item's current expiry time alone.
            // - If the queue does support ::delayItem(), we should allow the
            // queue to update the item's expiry using the requested delay.
            if ($queue instanceof DelayableQueueInterface) {

1) Use - to organize them in bullets, 2) requeued, should be re-queued probably, see #3138768: [Meta] Fix flagged spelling errors due to missing hyphens for prefixes

Otherwise, this is RTBC.

mrinalini9’s picture

Assigned: Unassigned » mrinalini9
mrinalini9’s picture

Assigned: mrinalini9 » Unassigned
Status: Needs work » Needs review
StatusFileSize
new20.01 KB
new1.01 KB

Updated patch #40 along with the changes suggested in #41, please review.

jungle’s picture

Status: Needs review » Reviewed & tested by the community

Thanks, @mrinalini9!

ghost of drupal past’s picture

When did we start adding scalar typehints? Is this a new policy for D9.1?

krzysztof domański’s picture

@Charlie ChX Negyesi For now only a few test methods require type hints. See [Meta] Implement strict typing in existing code and Overridden test methods require void return type hints. Since Drupal 9 requires PHP 7.3 we can make it better.

catch’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/lib/Drupal/Core/Cron.php
    @@ -180,6 +182,18 @@ protected function processQueues() {
    +            // The worker requested the task not be immediately re-queued.
    +            // - If the queue doesn't support ::delayItem(), we should leave the
    +            // item's current expiry time alone.
    +            // - If the queue does support ::delayItem(), we should allow the
    +            // queue to update the item's expiry using the requested delay.
    +            if ($queue instanceof DelayableQueueInterface) {
    +              // This queue can handle a custom delay; use the duration provided
    +              // by the exception.
    +              $queue->delayItem($item, $e->getDelay());
    +            }
    

    Should there be some kind of message logged when the queue doesn't support ::delayItem()?

  2. +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -171,6 +171,33 @@ public function releaseItem($item) {
    +    try {
    +      // Add the delay relative to the current time.
    +      $expire = time() + $delay;
    +      // Update the expiry time of this item.
    

    This should use the time service.

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new16.8 KB
new34.35 KB

No idea if this patch will pass testing, but here goes. I'm attempting to address #37.1 with additional testing.

In reply to #47:

  1. I'd prefer that there isn't; I've addressed the concern in this patch by adding additional documentation of expected behavior to the exception that is thrown by queue workers so that when people implement this functionality, they should now know what to expect.
  2. I've updated the database and memory queues to use the time service, as both of these were just using time(); this enabled me to mock the time service for reliable testing (I was cautious to avoid implementing unreliable testing in #38, but now we can do so safely).

I opted to skip the setup routine for testbot locally because that takes time that I don't have, so I'm relying on Drupal CI for initial complaints here, then I'll set up a local test bot if debugging becomes necessary.

jungle’s picture

About #47.2, maybe we could add a time-service related Trait which is similar to the \Drupal\Core\Messenger\MessengerTrait trait to bypass the BC concerns, For example, call $this->getTime() to get the "datetime.time" service instance.

clayfreeman’s picture

StatusFileSize
new11.51 KB
new29.47 KB

Attaching a new patch that introduces the trait proposed by @jungle in #49.

This patch successfully accommodates the testing requested by @alexpott in #37.1 and the criteria provided by @catch in #47.2. #47.1 is addressed by improved documentation on Drupal\Core\Queue\DelayedRequeueException.

Status: Needs review » Needs work

The last submitted patch, 50: 3116478-50.patch, failed testing. View results

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new590 bytes
new29.52 KB
jungle’s picture

Status: Needs review » Reviewed & tested by the community
  1. #47.1 @clayfreeman trended to not address it and explained in #48.1
  2. +++ b/core/lib/Drupal/Core/Datetime/TimeTrait.php
    @@ -0,0 +1,43 @@
    +trait TimeTrait {
    ...
    +  public function time() {
    +    if (!isset($this->time)) {
    +      $this->time = \Drupal::time();
    +    }
    +
    +    return $this->time;
    +  }
    +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -11,9 +12,10 @@
    +  use TimeTrait;
    
    @@ -171,6 +173,33 @@ public function releaseItem($item) {
    +      $expire = $this->time()->getCurrentTime() + $delay;
    

    #47.2 was addressed via introduced the TimeTrait which wrapped the\Drupal::time(); service and suggested by me in #49. I am not sure if it is a good idea. But if it's not, I would suggest moving forward here, furthermore, rescoping #3123216: Replace non-test usages of \Drupal::time() with IoC injection and refactoring it with IoC in #3123216, removing the TimeTrait there if necessary.

So setting back to RTBC, to have opinions from committer(s) again.

Thanks!

clayfreeman’s picture

Follow-up for committers on #53.2:

The BC concern that we're trying to avoid stems from a complication in core/includes/form.inc:1022.

For DatabaseQueue, this is fine, but for MemoryQueue (or any other queue), this could pose issues. This is why we ended up adding TimeTrait.

See this conversation on Slack for more information. There are several alternatives that require committer direction:

  1. We drop the TimeTrait in favor of adding it in a separate ticket that blocks this one. This could slow things up quite dramatically here and my team is quite eager to get this particular issue resolved. Aside from proper issue scoping, is there any value-add in doing this?
  2. We omnibus this issue with the new TimeTrait. While technically out of scope, we are testing it implicitly so if it works for the queue subsystem, it should work anywhere else. Since the work is already done, this may serve as a decent compromise. We also get the added benefit of better testing in this issue.
  3. We leave \Drupal::time() usage alone (ignoring #47.2) and add a follow-up issue (or maybe extend the scope of #3123216: Replace non-test usages of \Drupal::time() with IoC injection?) to replace it with dependency injection at a later date. This will require us to remove testing that is valuable to this issue.

There may be more options that I'm missing; just wanted to lay out the obvious options and their pros/cons.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

I discussed this issue with @catch. Never of us are keen on adding a TimeTrait in this issue. I think the best way forward here is use \Drupal::time() here and then open a follow-up to change _batch_queue to use the class resolver service so queue classes can get their dependencies injected.

Whilst looking at the above I also realised that one impact of this change is that now core's batches via \Drupal\Core\Queue\Batch will automatically support this functionality. I'm not sure whether this is an intended side-effect - or even desired - I've not thought it through yet. But, if Batch does support this then I think we need to make sure that that is documented properly.

clayfreeman’s picture

Status: Needs work » Needs review
StatusFileSize
new7.87 KB
new28.52 KB

@alexpott thanks for taking the time to review and leave feedback.

This patch removes the TimeTrait and instead uses \Drupal::time() as a fallback. Luckily, we're still able to override this service to provide a reliable time base for these tests (contrary to what I suspected).

I'm not familiar with the batch subsystem, so I'll need further guidance on what to do to make sure the documentation updates are squared away.

I believe #3123216: Replace non-test usages of \Drupal::time() with IoC injection is well-scoped enough to accommodate your request for a follow-up issue to replace \Drupal::time(). If you disagree, I can open a separate issue or the scope of that issue can be adjusted when this is merged.

andypost’s picture

  1. +++ b/core/lib/Drupal/Core/Queue/DatabaseQueue.php
    @@ -89,7 +89,7 @@ protected function doCreateItem($data) {
    -        'created' => time(),
    +        'created' => \Drupal::time()->getCurrentTime(),
    
    @@ -140,7 +140,7 @@ public function claimItem($lease_time = 30) {
    -          'expire' => time() + $lease_time,
    +          'expire' => \Drupal::time()->getCurrentTime() + $lease_time,
    
    @@ -171,6 +171,33 @@ public function releaseItem($item) {
    +      $expire = \Drupal::time()->getCurrentTime() + $delay;
    
    +++ b/core/lib/Drupal/Core/Queue/Memory.php
    @@ -12,6 +12,7 @@
     class Memory implements QueueInterface {
    +
    
    @@ -44,7 +45,7 @@ public function createItem($data) {
    -    $item->created = time();
    +    $item->created = \Drupal::time()->getCurrentTime();
    
    @@ -63,7 +64,7 @@ public function numberOfItems() {
    -        $item->expire = time() + $lease_time;
    +        $item->expire = \Drupal::time()->getCurrentTime() + $lease_time;
    

    this changes looks out of scope but could use separate issue to inject time service otoh all this places are valid to use time() as exact system time is needed. Better to add new child into #2729597: [meta] Replace \Drupal with injected services where appropriate in core

    The only reason to do injection of new service is better ability to unit-test the code, but not sure it makes sense

  2. +++ b/core/lib/Drupal/Core/Queue/DelayableQueueInterface.php
    @@ -0,0 +1,34 @@
    +   * @param mixed $item
    +   *   The item returned by \Drupal\Core\Queue\QueueInterface::claimItem().
    

    instead of "mixed" better use 'object'

  3. +++ b/core/lib/Drupal/Core/Queue/DelayedRequeueException.php
    @@ -0,0 +1,53 @@
    +  public function __construct(int $delay = 0) {
    +    if ($delay >= 0) {
    +      $this->delay = $delay;
    

    I think set value makes sense only when $delay > 0 (as default is 0 in class property)

  4. +++ b/core/lib/Drupal/Core/Queue/DelayedRequeueException.php
    @@ -0,0 +1,53 @@
    +  public function getDelay() {
    

    needs type-hint to int

jonathanshaw’s picture

#55 one impact of this change is that now core's batches via \Drupal\Core\Queue\Batch will automatically support this functionality. I'm not sure whether this is an intended side-effect - or even desired - I've not thought it through yet. But, if Batch does support this then I think we need to make sure that that is documented properly.

#56 I'm not familiar with the batch subsystem, so I'll need further guidance on what to do to make sure the documentation updates are squared away.

Batch operations are documented in core/includes/form.inc. Also core/lib/Drupal/Core/Form/form.api.php callback_batch_operation() may be a relevant place to document. Neither place currently mention anything about how exeptions (including the existing RequeueException) are handled.

But the first task is to establish what effect throwing DelayedRequeueException in a batch operation currently has with this patch. Does the item get processed again at the end of the batch? Does it get left in the queue for the next time the batch is run? Does it get orphaned in the queue and will never run? Does it get silently discarded? It's possible this needs a test.

clayfreeman’s picture

StatusFileSize
new1.41 KB
new28.52 KB

Re #57:

  1. I personally disagree. The reward in this scenario far outweighs the risk. Drupal would be at a greater detriment without the tests afforded by switching time() for \Drupal::time()->getCurrentTime(). Please refer to the changes made to \Drupal\Tests\system\Kernel\System\CronQueueTest by this patch; a stable time base is extremely important to be able to reliably test the functionality of the proposed change(s).
  2. Done.
  3. Done.
  4. Done.

Re #58:

  1. Since the only change to control flow was in \Drupal\Core\Cron, I don't see how this specific change has any impact on batch. The two subsystems appear to be unrelated at first glance.
  2. The only area where I could see cause for concern is if DelayableQueueInterface::delayItem() was used, but \Drupal\Core\Queue\Batch::claimItem() disregards item expiry entirely in its query. \Drupal\Core\Queue\BatchMemory isn't susceptible to any repercussions since it doesn't implement DelayableQueueInterface.
  3. If you still think a test is needed for this, I'd be more than happy to oblige.
jonathanshaw’s picture

\Drupal\Core\Queue\Batch::claimItem() disregards item expiry entirely in its query. \Drupal\Core\Queue\BatchMemory isn't susceptible to any repercussions since it doesn't implement DelayableQueueInterface.

Makes sense.

Seems to me the feature is RTBC, but the wonderfully extensive test coverage could use a few tweaks.

  1. +++ b/core/modules/system/tests/modules/cron_queue_test/src/Plugin/QueueWorker/CronQueueTestException.php
    @@ -13,17 +16,36 @@
    +      $state->set('cron_queue_test_exception', self::GENERIC);
    ...
    +      $state->set('cron_queue_test_exception', self::NONE);
    +    }
    ...
    +      $state->set('cron_queue_test_exception', self::DELAYED);
    

    Updating the state from within the test queue plugin seems odd. I understand it was how it was done before, but now that this test is getting more complicated maybe it would be best to move these into testExceptions() to make the flow of the test logic more explicit.

  2. +++ b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php
    @@ -41,6 +70,75 @@ protected function setUp(): void {
    +  public function testTimeService() {
    +    $this->assertEquals($this->currentTime, \Drupal::time()->getCurrentTime());
    +    $this->assertEquals($this->currentTime, \Drupal::time()->getRequestTime());
    +  }
    

    Probably better to do these sanity tests within setup rather than a a seperate test.

  3. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,195 @@
    +   * This test must run in a separate process so that our custom
    +   * `watchdog_exception()` doesn't pollute global scope for other tests.
    ...
    +    /**
    +     * Fake watchdog_exception() function used to throw an exception.
    +     */
    +    function watchdog_exception($type, \Exception $exception, ...$args) {
    

    watchdog_exception is scheduled for deprecation in #2932518: Deprecate watchdog_exception. What we usually do is mock/prophesize the logger. See e.g. core\tests\Drupal\Tests\Core\Block\BlockManagerTest

  4. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,195 @@
    +    public function testProcessQueues() {
    

    Feels like this could use a dataProvider to be more DRY

clayfreeman’s picture

StatusFileSize
new25.74 KB
new21.43 KB
  1. Instead of doing this, I just reverted all changes that were made to this test case and the associated queue worker in my patch. The changes were starting to feel out of scope to me and the additional assertions that I made were eventually duplicated (more precisely) by ::testDelayException().
  2. Thusly moved to ::setUp().
  3. The watchdog_exception() definition has been removed from the test, but there will be additional follow-up for the referenced issue since watchdog_exception() would otherwise be undefined. Let me know if I took the right approach and whether I should proceed to update the scope of #2932518: Deprecate watchdog_exception.
  4. Good recommendation!
jonathanshaw’s picture

  1. I find The difference between the memory queue and database queue test plugins, and how they're tested and asserted, hard to wrap my head around. But it is well commented and it does serve a purpose, so I think it's OK. I can't seem how to simplify it without losing some angle of test coverage
  2. +++ b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php
    @@ -41,6 +61,77 @@ protected function setUp(): void {
    +  protected function tearDown(): void {
    +    $this->container->set('queue', $this->realQueueFactory);
    +  }
    

    I'm surprised if this is needed. Drupal's test isolation is usually rather good as is.

  3. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,170 @@
    +    // Create a mock logger to set a flag in the resulting state.
    +    $logger = $this->prophesize('Drupal\Core\Logger\LoggerChannelInterface');
    +    $logger->log(Argument::cetera())->will(function () {
    +      \Drupal::state()->set('cron_test.message_logged', TRUE);
    +    });
    

    The obvious question here is why we're not using $logger->expects() and are doing this complicated state dance instead. At the very least this needs a comment explaining. I suspect the reason is that we need to know the arguments from the dataProvider to set the logger expectation and we don't know these in setUp(). But why not move the logger setup into the test method itself? Seems cleaner to me.

  4. +++ b/core/tests/Drupal/Tests/Core/CronTest.php
    @@ -0,0 +1,170 @@
    +      if ($method_calls === 1) {
    +        throw new DelayedRequeueException();
    +      }
    

    It would seem conceptually simpler to have this item return NULL on the second call, the same as the 'Complete' item? Because RequeueException is what we care about here, not DelayedRequeueException.

clayfreeman’s picture

StatusFileSize
new9.08 KB
new23.16 KB

I noticed some additional things that needed testing, namely the requeue count for Drupal\Core\Queue\RequeueException, and improved the compatibility of this test with #2932518: Deprecate watchdog_exception when it's merged by adding a mock for each method of Psr\Log\LoggerInterface.

In response to your review:

  1. OK.
  2. Removed.
  3. I'd argue that the state flag is cognitively less complex and will be easier to adapt to the evolving code base of Drupal moving forward.
  4. Done.
jonathanshaw’s picture

Status: Needs review » Reviewed & tested by the community
mxr576’s picture

Awesome! I had to implement this feature in Drupal 7 from scratch for processing a huge amount data via a 3rd party system that could have failed anytime, it was years ago but this feature finally gets supported in Drupal 8 9! RTBC++

mikechr’s picture

This also works for me

catch’s picture

Status: Reviewed & tested by the community » Needs work

The test coverage is a bit tricky here, but I don't have ideas to simplify it, and the coverage it adds is good.

We need to add 'delayable' to the cspell dictionary, but otherwise RTBC for me.

CSPELL: checking all files
..core/lib/Drupal/Core/Cron.php:11:23 - Unknown word (Delayable)

Also we should open an issue against drush to support this once it's added here - since that's the main queue runner in contrib.

jungle’s picture

Status: Needs work » Reviewed & tested by the community
Issue tags: +Needs followup
StatusFileSize
new0 bytes
new299 bytes

Added "delayable" into core/misc/cspell/dictionary.txt

$ yarn spellcheck:core
yarn run v1.22.4
$ cspell "**/*" "../composer/**/*" "../composer.json"
CSpell: Files checked: 14725, Issues found: 0 in 0 files
✨  Done in 249.56s.
Also we should open an issue against drush to support this once it's added here - since that's the main queue runner in contrib.

Tagging "Needs followup" for this.

Setting back to RTBC.

Thanks!

clayfreeman’s picture

StatusFileSize
new299 bytes
new23.45 KB

Re-upload to fix error in @jungle's reply. No changes, aside from creating a non-empty patch.

jungle’s picture

Oh, my bad, @clayfreeman, thanks!

  • catch committed 0f10d21 on 9.1.x
    Issue #3116478 by clayfreeman, jungle, mrinalini9, jonathanshaw,...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed 0f10d21 and pushed to 9.1.x. Thanks!

catch’s picture

Issue tags: -Needs followup

Opened the follow-up for drush: https://github.com/drush-ops/drush/issues/4543

clayfreeman’s picture

Issue summary: View changes

Thanks everyone for your input. This has been quite a journey to my first Drupal core contribution, but I'm very happy with the end result.

Just to note, the patch in #63 should also apply cleanly to 8.9.x for anyone needing this in D8; the only difference is the lack of an update to core/misc/cspell/dictionary.txt, which didn't exist until D9.

Also included some small fixes to the issue summary.

jungle’s picture

@clayfreeman, congrats on your first Drupal core contribution!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

dpi’s picture

Created #3177922: DelayedRequeueException should call parent, and optionally allow providing default args to cover improving \Drupal\Core\Queue\DelayedRequeueException.

br0ken’s picture

br0ken’s picture

#3183220: Add support for pre-delayed queue items

The follow-up on this issue: allow mutating the queue item's data between processing attempts.

larowlan’s picture

Folks may be interested in #3198868: Add delay to queue suspend too

quietone’s picture

I don't think this is related to implementing the coding standards in core.