| @@ -8,16 +8,21 @@ | ||
| 8 | 8 | class JobsRunnerTest extends TestCase |
| 9 | 9 | { |
| 10 | 10 | private const ASYNC_ACTION = 'sync_basalam_run_jobs_async'; |
| 11 | 11 | private const DISPATCH_LOCK = 'sync_basalam_jobs_runner_async_dispatch_lock'; |
| 12 | + private const IDLE_PROBE_LOCK = 'sync_basalam_jobs_runner_idle_probe_lock'; | |
| 12 | 13 | |
| 13 | 14 | private $originalRequest; |
| 14 | 15 | private $originalCookie; |
| 16 | + private $originalWpdb; | |
| 17 | + private $hadOriginalWpdb; | |
| 15 | 18 | |
| 16 | 19 | protected function setUp(): void |
| 17 | 20 | { |
| 18 | 21 | $this->originalRequest = $_REQUEST; |
| 19 | 22 | $this->originalCookie = $_COOKIE; |
| 23 | + $this->hadOriginalWpdb = array_key_exists('wpdb', $GLOBALS); | |
| 24 | + $this->originalWpdb = $GLOBALS['wpdb'] ?? null; | |
| 20 | 25 | |
| 21 | 26 | $_REQUEST = []; |
| 22 | 27 | $_COOKIE = []; |
| 23 | 28 | |
| @@ -23,8 +28,9 @@ | ||
| 23 | 28 | |
| 24 | 29 | $GLOBALS['sync_basalam_jobs_runner_test_state'] = [ |
| 25 | 30 | 'actions' => [], |
| 26 | 31 | 'did_actions' => [], |
| 32 | + 'current_filter' => '', | |
| 27 | 33 | 'doing_ajax' => false, |
| 28 | 34 | 'transients' => [], |
| 29 | 35 | 'transient_reads' => [], |
| 30 | 36 | 'transient_writes' => [], |
| @@ -38,21 +44,27 @@ | ||
| 38 | 44 | { |
| 39 | 45 | $_REQUEST = $this->originalRequest; |
| 40 | 46 | $_COOKIE = $this->originalCookie; |
| 41 | 47 | |
| 48 | + if ($this->hadOriginalWpdb) { | |
| 49 | + $GLOBALS['wpdb'] = $this->originalWpdb; | |
| 50 | + } else { | |
| 51 | + unset($GLOBALS['wpdb']); | |
| 52 | + } | |
| 53 | + | |
| 42 | 54 | unset($GLOBALS['sync_basalam_jobs_runner_test_state']); |
| 43 | 55 | } |
| 44 | 56 | |
| 45 | - public function testRegistersDispatcherOnInitAndNeverOnShutdown(): void | |
| 57 | + public function testRegistersDispatcherOnShutdownAndNeverOnInit(): void | |
| 46 | 58 | { |
| 47 | 59 | $runner = $this->newRunner(); |
| 48 | 60 | $actions = $this->state()['actions']; |
| 49 | 61 | |
| 50 | - self::assertArrayHasKey('init', $actions); | |
| 51 | - self::assertSame([$runner, 'maybeDispatchAsyncRequest'], $actions['init'][0]['callback']); | |
| 52 | - self::assertSame(10, $actions['init'][0]['priority']); | |
| 53 | - self::assertSame(0, $actions['init'][0]['accepted_args']); | |
| 54 | - self::assertArrayNotHasKey('shutdown', $actions); | |
| 62 | + self::assertArrayHasKey('shutdown', $actions); | |
| 63 | + self::assertSame([$runner, 'maybeDispatchAsyncRequest'], $actions['shutdown'][0]['callback']); | |
| 64 | + self::assertSame(2, $actions['shutdown'][0]['priority']); | |
| 65 | + self::assertSame(1, $actions['shutdown'][0]['accepted_args']); | |
| 66 | + self::assertArrayNotHasKey('init', $actions); | |
| 55 | 67 | |
| 56 | 68 | self::assertSame( |
| 57 | 69 | [$runner, 'maybeDispatchAsyncRequest'], |
| 58 | 70 | $actions['sync_basalam_job_created'][0]['callback'] |
| @@ -64,9 +76,9 @@ | ||
| 64 | 76 | $actions['wp_ajax_nopriv_' . self::ASYNC_ACTION][0]['callback'] |
| 65 | 77 | ); |
| 66 | 78 | } |
| 67 | 79 | |
| 68 | - public function testEligibleQueueIsConfirmedBeforeWritingLockAndDispatching(): void | |
| 80 | + public function testDispatchLeaseIsWrittenOnlyWhenQueueHasWork(): void | |
| 69 | 81 | { |
| 70 | 82 | $jobManager = new FakeJobManager([true]); |
| 71 | 83 | $runner = $this->newRunner($jobManager); |
| 72 | 84 | |
| @@ -73,9 +85,9 @@ | ||
| 73 | 85 | $_COOKIE = ['wordpress_test_cookie' => 'cookie-value']; |
| 74 | 86 | $runner->maybeDispatchAsyncRequest(); |
| 75 | 87 | |
| 76 | 88 | self::assertSame( |
| 77 | - ['get_transient', 'has_pending_jobs', 'set_transient', 'remote_post'], | |
| 89 | + ['get_transient', 'get_transient', 'has_pending_jobs', 'get_transient', 'set_transient', 'remote_post'], | |
| 78 | 90 | $this->state()['events'] |
| 79 | 91 | ); |
| 80 | 92 | self::assertSame([120], $jobManager->timeouts); |
| 81 | 93 | self::assertSame( |
| @@ -81,9 +93,9 @@ | ||
| 81 | 93 | self::assertSame( |
| 82 | 94 | [[ |
| 83 | 95 | 'name' => self::DISPATCH_LOCK, |
| 84 | 96 | 'value' => 1, |
| 85 | - 'expiration' => 1, | |
| 97 | + 'expiration' => 25, | |
| 86 | 98 | ]], |
| 87 | 99 | $this->state()['transient_writes'] |
| 88 | 100 | ); |
| 89 | 101 | |
| @@ -99,35 +111,66 @@ | ||
| 99 | 111 | self::assertSame('test-nonce-for-' . self::ASYNC_ACTION, $requests[0]['args']['body']['nonce']); |
| 100 | 112 | self::assertSame($_COOKIE, $requests[0]['args']['cookies']); |
| 101 | 113 | } |
| 102 | 114 | |
| 103 | - public function testEmptyQueueDoesNotWriteLockOrDispatch(): void | |
| 115 | + public function testShutdownRepairsDatabaseBeforeUsingQueueApis(): void | |
| 104 | 116 | { |
| 117 | + $GLOBALS['sync_basalam_jobs_runner_test_state']['current_filter'] = 'shutdown'; | |
| 118 | + $GLOBALS['wpdb'] = new FakeWpdb(); | |
| 119 | + | |
| 105 | 120 | $jobManager = new FakeJobManager([false]); |
| 106 | 121 | $runner = $this->newRunner($jobManager); |
| 107 | 122 | |
| 108 | 123 | $runner->maybeDispatchAsyncRequest(); |
| 109 | 124 | |
| 110 | - self::assertSame(['get_transient', 'has_pending_jobs'], $this->state()['events']); | |
| 125 | + self::assertSame( | |
| 126 | + [ | |
| 127 | + 'fastcgi_finish_request', | |
| 128 | + 'db_flush', | |
| 129 | + 'db_check_connection', | |
| 130 | + 'get_transient', | |
| 131 | + 'get_transient', | |
| 132 | + 'has_pending_jobs', | |
| 133 | + 'set_transient', | |
| 134 | + ], | |
| 135 | + $this->state()['events'] | |
| 136 | + ); | |
| 137 | + self::assertSame([false], $GLOBALS['wpdb']->allowBailValues); | |
| 138 | + } | |
| 139 | + | |
| 140 | + public function testEmptyQueueReservesOnlyShortIdleProbeLease(): void | |
| 141 | + { | |
| 142 | + $jobManager = new FakeJobManager([false]); | |
| 143 | + $runner = $this->newRunner($jobManager); | |
| 144 | + | |
| 145 | + $runner->maybeDispatchAsyncRequest(); | |
| 146 | + | |
| 147 | + self::assertSame(['get_transient', 'get_transient', 'has_pending_jobs', 'set_transient'], $this->state()['events']); | |
| 111 | 148 | self::assertSame([120], $jobManager->timeouts); |
| 112 | - self::assertSame([], $this->state()['transient_writes']); | |
| 149 | + self::assertSame( | |
| 150 | + [[ | |
| 151 | + 'name' => self::IDLE_PROBE_LOCK, | |
| 152 | + 'value' => 1, | |
| 153 | + 'expiration' => 5, | |
| 154 | + ]], | |
| 155 | + $this->state()['transient_writes'] | |
| 156 | + ); | |
| 113 | 157 | self::assertSame([], $this->state()['remote_requests']); |
| 114 | 158 | } |
| 115 | 159 | |
| 116 | - public function testJobCreatedAfterAnEmptyInitCanStillDispatch(): void | |
| 160 | + public function testNewJobBypassesIdleProbeLeaseAndDispatchesImmediately(): void | |
| 117 | 161 | { |
| 118 | 162 | $jobManager = new FakeJobManager([false, true]); |
| 119 | 163 | $runner = $this->newRunner($jobManager); |
| 120 | 164 | |
| 121 | - // The init probe sees no work and must not reserve the dispatch lock. | |
| 122 | 165 | $runner->maybeDispatchAsyncRequest(); |
| 123 | - self::assertSame([], $this->state()['transient_writes']); | |
| 166 | + self::assertCount(1, $this->state()['transient_writes']); | |
| 124 | 167 | |
| 125 | - // A job created later in the same request must still wake the runner. | |
| 168 | + $GLOBALS['sync_basalam_jobs_runner_test_state']['current_filter'] = 'sync_basalam_job_created'; | |
| 126 | 169 | $runner->maybeDispatchAsyncRequest(); |
| 127 | 170 | |
| 128 | 171 | self::assertSame([120, 120], $jobManager->timeouts); |
| 129 | - self::assertCount(1, $this->state()['transient_writes']); | |
| 172 | + self::assertSame(self::DISPATCH_LOCK, $this->state()['transient_writes'][1]['name']); | |
| 130 | 173 | self::assertCount(1, $this->state()['remote_requests']); |
| 131 | 174 | } |
| 132 | 175 | |
| 133 | 176 | public function testExistingLockSkipsQueueCheckAndDispatch(): void |
| @@ -137,9 +180,9 @@ | ||
| 137 | 180 | $runner = $this->newRunner($jobManager); |
| 138 | 181 | |
| 139 | 182 | $runner->maybeDispatchAsyncRequest(); |
| 140 | 183 | |
| 141 | - self::assertSame(['get_transient'], $this->state()['events']); | |
| 184 | + self::assertSame(['get_transient', 'get_transient'], $this->state()['events']); | |
| 142 | 185 | self::assertSame([], $jobManager->timeouts); |
| 143 | 186 | self::assertSame([], $this->state()['transient_writes']); |
| 144 | 187 | self::assertSame([], $this->state()['remote_requests']); |
| 145 | 188 | } |
| @@ -170,34 +213,53 @@ | ||
| 170 | 213 | self::assertSame(1, $httpBlockService->calls); |
| 171 | 214 | $this->assertNoQueueLockOrDispatchActivity($jobManager); |
| 172 | 215 | } |
| 173 | 216 | |
| 174 | - public function testShutdownGuardPreventsEveryDispatchSideEffect(): void | |
| 217 | + public function testAsyncBatchExitsImmediatelyWhenAnotherRunnerOwnsTheGlobalLock(): void | |
| 175 | 218 | { |
| 176 | - $GLOBALS['sync_basalam_jobs_runner_test_state']['did_actions']['shutdown'] = 1; | |
| 219 | + $jobManager = new FakeJobManager([true]); | |
| 220 | + $jobExecutor = new FakeJobExecutor(false); | |
| 221 | + $runner = $this->newRunner($jobManager, null, $jobExecutor); | |
| 177 | 222 | |
| 178 | - $jobManager = new FakeJobManager([true]); | |
| 179 | - $httpBlockService = new FakeHttpBlockService(false); | |
| 180 | - $runner = $this->newRunner($jobManager, $httpBlockService); | |
| 223 | + self::assertSame(0, $this->invokeRunAsyncBatch($runner)); | |
| 224 | + self::assertSame(1, $jobExecutor->acquireCalls); | |
| 225 | + self::assertSame(0, $jobExecutor->releaseCalls); | |
| 226 | + self::assertSame([], $jobManager->timeouts); | |
| 227 | + } | |
| 181 | 228 | |
| 182 | - $runner->maybeDispatchAsyncRequest(); | |
| 229 | + public function testAsyncBatchReleasesTheGlobalLockWhenTheQueueIsEmpty(): void | |
| 230 | + { | |
| 231 | + $jobManager = new FakeJobManager([false]); | |
| 232 | + $jobExecutor = new FakeJobExecutor(true); | |
| 233 | + $runner = $this->newRunner($jobManager, null, $jobExecutor); | |
| 183 | 234 | |
| 184 | - self::assertSame(0, $httpBlockService->calls); | |
| 185 | - $this->assertNoQueueLockOrDispatchActivity($jobManager); | |
| 235 | + self::assertSame(0, $this->invokeRunAsyncBatch($runner)); | |
| 236 | + self::assertSame(1, $jobExecutor->acquireCalls); | |
| 237 | + self::assertSame(1, $jobExecutor->releaseCalls); | |
| 238 | + self::assertSame([120], $jobManager->timeouts); | |
| 186 | 239 | } |
| 187 | 240 | |
| 188 | 241 | private function newRunner( |
| 189 | 242 | ?FakeJobManager $jobManager = null, |
| 190 | - ?FakeHttpBlockService $httpBlockService = null | |
| 243 | + ?FakeHttpBlockService $httpBlockService = null, | |
| 244 | + $jobExecutor = null | |
| 191 | 245 | ): JobsRunner { |
| 192 | 246 | return new JobsRunner( |
| 193 | 247 | $jobManager ?? new FakeJobManager(), |
| 248 | + $jobExecutor ?? new FakeJobExecutor(), | |
| 194 | 249 | new \stdClass(), |
| 195 | - new \stdClass(), | |
| 196 | 250 | $httpBlockService ?? new FakeHttpBlockService(false) |
| 197 | 251 | ); |
| 198 | 252 | } |
| 199 | 253 | |
| 254 | + private function invokeRunAsyncBatch(JobsRunner $runner): int | |
| 255 | + { | |
| 256 | + $method = new \ReflectionMethod($runner, 'runAsyncBatch'); | |
| 257 | + $method->setAccessible(true); | |
| 258 | + | |
| 259 | + return $method->invoke($runner); | |
| 260 | + } | |
| 261 | + | |
| 200 | 262 | private function assertNoQueueLockOrDispatchActivity(FakeJobManager $jobManager): void |
| 201 | 263 | { |
| 202 | 264 | self::assertSame([], $jobManager->timeouts); |
| 203 | 265 | self::assertSame([], $this->state()['transient_reads']); |
| @@ -230,8 +292,26 @@ | ||
| 230 | 292 | return (bool) array_shift($this->pendingResults); |
| 231 | 293 | } |
| 232 | 294 | } |
| 233 | 295 | |
| 296 | +class FakeWpdb | |
| 297 | +{ | |
| 298 | + public $allowBailValues = []; | |
| 299 | + | |
| 300 | + public function flush(): void | |
| 301 | + { | |
| 302 | + $GLOBALS['sync_basalam_jobs_runner_test_state']['events'][] = 'db_flush'; | |
| 303 | + } | |
| 304 | + | |
| 305 | + public function check_connection($allowBail = true): bool | |
| 306 | + { | |
| 307 | + $GLOBALS['sync_basalam_jobs_runner_test_state']['events'][] = 'db_check_connection'; | |
| 308 | + $this->allowBailValues[] = $allowBail; | |
| 309 | + | |
| 310 | + return true; | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 234 | 314 | class FakeHttpBlockService |
| 235 | 315 | { |
| 236 | 316 | public $calls = 0; |
| 237 | 317 | |
| @@ -246,6 +326,33 @@ | ||
| 246 | 326 | { |
| 247 | 327 | $this->calls++; |
| 248 | 328 | |
| 249 | 329 | return $this->blocked; |
| 330 | + } | |
| 331 | +} | |
| 332 | + | |
| 333 | +class FakeJobExecutor | |
| 334 | +{ | |
| 335 | + public $acquireCalls = 0; | |
| 336 | + public $releaseCalls = 0; | |
| 337 | + | |
| 338 | + private $canAcquire; | |
| 339 | + | |
| 340 | + public function __construct(bool $canAcquire = true) | |
| 341 | + { | |
| 342 | + $this->canAcquire = $canAcquire; | |
| 343 | + } | |
| 344 | + | |
| 345 | + public function acquireGlobalJobsLock(int $timeout = 0): bool | |
| 346 | + { | |
| 347 | + $this->acquireCalls++; | |
| 348 | + | |
| 349 | + return $this->canAcquire; | |
| 350 | + } | |
| 351 | + | |
| 352 | + public function releaseGlobalJobsLock(): bool | |
| 353 | + { | |
| 354 | + $this->releaseCalls++; | |
| 355 | + | |
| 356 | + return true; | |
| 250 | 357 | } |
| 251 | 358 | } |