PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.29
ووسلام – همگام سازی ووکامرس و باسلام v1.10.29
1.10.29 1.10.28 1.1.26 1.10.27 1.10.24 1.10.25 1.10.23 1.10.22 1.10.21 1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 All 62 releases
← All changes | tests/JobsRunnerTest.php +256 -36 1.10.14 → 1.10.29 View file →
@@ -8,23 +8,34 @@
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;
18 + private $originalOptions;
19 + private $hadOriginalOptions;
15 20
16 21 protected function setUp(): void
17 22 {
18 23 $this->originalRequest = $_REQUEST;
19 24 $this->originalCookie = $_COOKIE;
25 + $this->hadOriginalWpdb = array_key_exists('wpdb', $GLOBALS);
26 + $this->originalWpdb = $GLOBALS['wpdb'] ?? null;
27 + $this->hadOriginalOptions = array_key_exists('sync_basalam_test_options', $GLOBALS);
28 + $this->originalOptions = $GLOBALS['sync_basalam_test_options'] ?? null;
20 29
21 30 $_REQUEST = [];
22 31 $_COOKIE = [];
32 + $GLOBALS['sync_basalam_test_options'] = [];
23 33
24 34 $GLOBALS['sync_basalam_jobs_runner_test_state'] = [
25 35 'actions' => [],
26 36 'did_actions' => [],
37 + 'current_filter' => '',
27 38 'doing_ajax' => false,
28 39 'transients' => [],
29 40 'transient_reads' => [],
30 41 'transient_writes' => [],
@@ -38,21 +49,32 @@
38 49 {
39 50 $_REQUEST = $this->originalRequest;
40 51 $_COOKIE = $this->originalCookie;
41 52
53 + if ($this->hadOriginalWpdb) {
54 + $GLOBALS['wpdb'] = $this->originalWpdb;
55 + } else {
56 + unset($GLOBALS['wpdb']);
57 + }
58 +
42 59 unset($GLOBALS['sync_basalam_jobs_runner_test_state']);
60 + if ($this->hadOriginalOptions) {
61 + $GLOBALS['sync_basalam_test_options'] = $this->originalOptions;
62 + } else {
63 + unset($GLOBALS['sync_basalam_test_options']);
64 + }
43 65 }
44 66
45 - public function testRegistersDispatcherOnInitAndNeverOnShutdown(): void
67 + public function testRegistersDispatcherOnShutdownAndNeverOnInit(): void
46 68 {
47 69 $runner = $this->newRunner();
48 70 $actions = $this->state()['actions'];
49 71
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);
72 + self::assertArrayHasKey('shutdown', $actions);
73 + self::assertSame([$runner, 'maybeDispatchAsyncRequest'], $actions['shutdown'][0]['callback']);
74 + self::assertSame(2, $actions['shutdown'][0]['priority']);
75 + self::assertSame(1, $actions['shutdown'][0]['accepted_args']);
76 + self::assertArrayNotHasKey('init', $actions);
55 77
56 78 self::assertSame(
57 79 [$runner, 'maybeDispatchAsyncRequest'],
58 80 $actions['sync_basalam_job_created'][0]['callback']
@@ -64,29 +86,28 @@
64 86 $actions['wp_ajax_nopriv_' . self::ASYNC_ACTION][0]['callback']
65 87 );
66 88 }
67 89
68 - public function testEligibleQueueIsConfirmedBeforeWritingLockAndDispatching(): void
90 + public function testDispatchLeaseIsWrittenOnlyWhenQueueHasWork(): void
69 91 {
70 92 $jobManager = new FakeJobManager([true]);
71 93 $runner = $this->newRunner($jobManager);
72 94
73 95 $_COOKIE = ['wordpress_test_cookie' => 'cookie-value'];
96 + $before = time();
74 97 $runner->maybeDispatchAsyncRequest();
75 98
76 99 self::assertSame(
77 - ['get_transient', 'has_pending_jobs', 'set_transient', 'remote_post'],
100 + ['get_transient', 'get_transient', 'has_pending_jobs', 'get_transient', 'set_transient', 'remote_post'],
78 101 $this->state()['events']
79 102 );
80 103 self::assertSame([120], $jobManager->timeouts);
81 - self::assertSame(
82 - [[
83 - 'name' => self::DISPATCH_LOCK,
84 - 'value' => 1,
85 - 'expiration' => 1,
86 - ]],
87 - $this->state()['transient_writes']
88 - );
104 + $writes = $this->state()['transient_writes'];
105 + self::assertCount(1, $writes);
106 + self::assertSame(self::DISPATCH_LOCK, $writes[0]['name']);
107 + self::assertSame(25, $writes[0]['expiration']);
108 + self::assertGreaterThanOrEqual($before + 25, $writes[0]['value']);
109 + self::assertLessThanOrEqual(time() + 25, $writes[0]['value']);
89 110
90 111 $requests = $this->state()['remote_requests'];
91 112 self::assertCount(1, $requests);
92 113 self::assertSame(
@@ -99,52 +120,175 @@
99 120 self::assertSame('test-nonce-for-' . self::ASYNC_ACTION, $requests[0]['args']['body']['nonce']);
100 121 self::assertSame($_COOKIE, $requests[0]['args']['cookies']);
101 122 }
102 123
103 - public function testEmptyQueueDoesNotWriteLockOrDispatch(): void
124 + public function testShutdownRepairsDatabaseWithoutFinishingFastCgiResponse(): void
104 125 {
126 + $GLOBALS['sync_basalam_jobs_runner_test_state']['current_filter'] = 'shutdown';
127 + $GLOBALS['wpdb'] = new FakeWpdb();
128 +
105 129 $jobManager = new FakeJobManager([false]);
106 130 $runner = $this->newRunner($jobManager);
107 131
108 132 $runner->maybeDispatchAsyncRequest();
109 133
110 - self::assertSame(['get_transient', 'has_pending_jobs'], $this->state()['events']);
134 + self::assertSame(
135 + [
136 + 'db_flush',
137 + 'db_check_connection',
138 + 'get_transient',
139 + 'get_transient',
140 + 'has_pending_jobs',
141 + 'set_transient',
142 + ],
143 + $this->state()['events']
144 + );
145 + self::assertSame([false], $GLOBALS['wpdb']->allowBailValues);
146 + }
147 +
148 + public function testEmptyQueueReservesOnlyShortIdleProbeLease(): void
149 + {
150 + $jobManager = new FakeJobManager([false]);
151 + $runner = $this->newRunner($jobManager);
152 +
153 + $before = time();
154 + $runner->maybeDispatchAsyncRequest();
155 +
156 + self::assertSame(['get_transient', 'get_transient', 'has_pending_jobs', 'set_transient'], $this->state()['events']);
111 157 self::assertSame([120], $jobManager->timeouts);
112 - self::assertSame([], $this->state()['transient_writes']);
158 + $writes = $this->state()['transient_writes'];
159 + self::assertCount(1, $writes);
160 + self::assertSame(self::IDLE_PROBE_LOCK, $writes[0]['name']);
161 + self::assertSame(5, $writes[0]['expiration']);
162 + self::assertGreaterThanOrEqual($before + 5, $writes[0]['value']);
163 + self::assertLessThanOrEqual(time() + 5, $writes[0]['value']);
113 164 self::assertSame([], $this->state()['remote_requests']);
114 165 }
115 166
116 - public function testJobCreatedAfterAnEmptyInitCanStillDispatch(): void
167 + public function testNewJobBypassesIdleProbeLeaseAndDispatchesImmediately(): void
117 168 {
118 169 $jobManager = new FakeJobManager([false, true]);
119 170 $runner = $this->newRunner($jobManager);
120 171
121 - // The init probe sees no work and must not reserve the dispatch lock.
122 172 $runner->maybeDispatchAsyncRequest();
123 - self::assertSame([], $this->state()['transient_writes']);
173 + self::assertCount(1, $this->state()['transient_writes']);
124 174
125 - // A job created later in the same request must still wake the runner.
175 + $GLOBALS['sync_basalam_jobs_runner_test_state']['current_filter'] = 'sync_basalam_job_created';
126 176 $runner->maybeDispatchAsyncRequest();
127 177
128 178 self::assertSame([120, 120], $jobManager->timeouts);
129 - self::assertCount(1, $this->state()['transient_writes']);
179 + self::assertSame(self::DISPATCH_LOCK, $this->state()['transient_writes'][1]['name']);
130 180 self::assertCount(1, $this->state()['remote_requests']);
131 181 }
132 182
133 183 public function testExistingLockSkipsQueueCheckAndDispatch(): void
134 184 {
135 - $GLOBALS['sync_basalam_jobs_runner_test_state']['transients'][self::DISPATCH_LOCK] = 1;
185 + $GLOBALS['sync_basalam_jobs_runner_test_state']['transients'][self::DISPATCH_LOCK] = time() + 25;
136 186 $jobManager = new FakeJobManager([true]);
137 187 $runner = $this->newRunner($jobManager);
138 188
139 189 $runner->maybeDispatchAsyncRequest();
140 190
141 - self::assertSame(['get_transient'], $this->state()['events']);
191 + self::assertSame(['get_transient', 'get_transient'], $this->state()['events']);
142 192 self::assertSame([], $jobManager->timeouts);
143 193 self::assertSame([], $this->state()['transient_writes']);
144 194 self::assertSame([], $this->state()['remote_requests']);
145 195 }
146 196
197 + /** @dataProvider inactiveLeaseCases */
198 + public function testExpiredOrOrphanedLeaseResumesDispatch(
199 + string $name,
200 + string $format,
201 + int $leaseOffset,
202 + ?int $timeoutOffset
203 + ): void {
204 + $this->setLease($name, $format, $leaseOffset, $timeoutOffset);
205 + $jobManager = new FakeJobManager([true]);
206 + $runner = $this->newRunner($jobManager);
207 +
208 + $runner->maybeDispatchAsyncRequest();
209 +
210 + self::assertSame([120], $jobManager->timeouts);
211 + self::assertCount(1, $this->state()['remote_requests']);
212 + self::assertSame(self::DISPATCH_LOCK, $this->state()['transient_writes'][0]['name']);
213 + self::assertGreaterThan(time(), $this->state()['transient_writes'][0]['value']);
214 + }
215 +
216 + public function inactiveLeaseCases(): array
217 + {
218 + $cases = [];
219 + foreach ([self::DISPATCH_LOCK, self::IDLE_PROBE_LOCK] as $name) {
220 + $cases[$name . ': legacy without timeout'] = [$name, 'legacy', 0, null];
221 + $cases[$name . ': legacy string without timeout'] = [$name, 'legacy_string', 0, null];
222 + $cases[$name . ': legacy with expired timeout'] = [$name, 'legacy', 0, -1];
223 + $cases[$name . ': expired deadline without timeout'] = [$name, 'deadline', -1, null];
224 + $cases[$name . ': expired stored deadline without timeout'] = [$name, 'deadline_string', -1, null];
225 + $cases[$name . ': expired deadline with future timeout'] = [$name, 'deadline', -1, 999];
226 + $cases[$name . ': deadline expires at current second'] = [$name, 'deadline', 0, null];
227 + }
228 + return $cases;
229 + }
230 +
231 + /** @dataProvider activeLeaseCases */
232 + public function testActiveLeasePreservesDispatchThrottle(
233 + string $name,
234 + string $format,
235 + ?int $timeoutOffset,
236 + bool $externalCache
237 + ): void {
238 + $this->setLease($name, $format, 25, $timeoutOffset);
239 + $GLOBALS['sync_basalam_jobs_runner_test_state']['external_object_cache'] = $externalCache;
240 + $jobManager = new FakeJobManager([true]);
241 + $runner = $this->newRunner($jobManager);
242 +
243 + $runner->maybeDispatchAsyncRequest();
244 +
245 + self::assertSame([], $jobManager->timeouts);
246 + self::assertSame([], $this->state()['transient_writes']);
247 + self::assertSame([], $this->state()['remote_requests']);
248 + }
249 +
250 + public function activeLeaseCases(): array
251 + {
252 + $cases = [];
253 + foreach ([self::DISPATCH_LOCK, self::IDLE_PROBE_LOCK] as $name) {
254 + $cases[$name . ': legacy with valid timeout'] = [$name, 'legacy', 25, false];
255 + $cases[$name . ': legacy string with valid timeout'] = [$name, 'legacy_string', 25, false];
256 + $cases[$name . ': deadline without timeout'] = [$name, 'deadline', null, false];
257 + $cases[$name . ': stored deadline without timeout'] = [$name, 'deadline_string', null, false];
258 + $cases[$name . ': cached legacy without database timeout'] = [$name, 'legacy', null, true];
259 + }
260 + return $cases;
261 + }
262 +
263 + public function testLosingTimeoutDoesNotReleaseAnActiveDispatchLease(): void
264 + {
265 + $jobManager = new FakeJobManager([true, true]);
266 + $runner = $this->newRunner($jobManager);
267 + $runner->maybeDispatchAsyncRequest();
268 + unset($GLOBALS['sync_basalam_test_options']['_transient_timeout_' . self::DISPATCH_LOCK]);
269 +
270 + $runner->maybeDispatchAsyncRequest();
271 +
272 + self::assertSame([120], $jobManager->timeouts);
273 + self::assertCount(1, $this->state()['remote_requests']);
274 + }
275 +
276 + public function testDispatchRechecksLeaseAfterProbingQueue(): void
277 + {
278 + $jobManager = new FakeJobManager([true]);
279 + $jobManager->onProbe = static function (): void {
280 + $GLOBALS['sync_basalam_jobs_runner_test_state']['transients'][self::DISPATCH_LOCK] = time() + 25;
281 + };
282 + $runner = $this->newRunner($jobManager);
283 +
284 + $runner->maybeDispatchAsyncRequest();
285 +
286 + self::assertSame([120], $jobManager->timeouts);
287 + self::assertSame([], $this->state()['remote_requests']);
288 + self::assertSame([], $this->state()['transient_writes']);
289 + }
290 +
147 291 public function testSelfAsyncRequestReturnsBeforeQueueAndLockChecks(): void
148 292 {
149 293 $GLOBALS['sync_basalam_jobs_runner_test_state']['doing_ajax'] = true;
150 294 $_REQUEST['action'] = self::ASYNC_ACTION;
@@ -170,34 +314,63 @@
170 314 self::assertSame(1, $httpBlockService->calls);
171 315 $this->assertNoQueueLockOrDispatchActivity($jobManager);
172 316 }
173 317
174 - public function testShutdownGuardPreventsEveryDispatchSideEffect(): void
318 + public function testAsyncBatchExitsImmediatelyWhenAnotherRunnerOwnsTheGlobalLock(): void
175 319 {
176 - $GLOBALS['sync_basalam_jobs_runner_test_state']['did_actions']['shutdown'] = 1;
320 + $jobManager = new FakeJobManager([true]);
321 + $jobExecutor = new FakeJobExecutor(false);
322 + $runner = $this->newRunner($jobManager, null, $jobExecutor);
177 323
178 - $jobManager = new FakeJobManager([true]);
179 - $httpBlockService = new FakeHttpBlockService(false);
180 - $runner = $this->newRunner($jobManager, $httpBlockService);
324 + self::assertSame(0, $this->invokeRunAsyncBatch($runner));
325 + self::assertSame(1, $jobExecutor->acquireCalls);
326 + self::assertSame(0, $jobExecutor->releaseCalls);
327 + self::assertSame([], $jobManager->timeouts);
328 + }
181 329
182 - $runner->maybeDispatchAsyncRequest();
330 + public function testAsyncBatchReleasesTheGlobalLockWhenTheQueueIsEmpty(): void
331 + {
332 + $jobManager = new FakeJobManager([false]);
333 + $jobExecutor = new FakeJobExecutor(true);
334 + $runner = $this->newRunner($jobManager, null, $jobExecutor);
183 335
184 - self::assertSame(0, $httpBlockService->calls);
185 - $this->assertNoQueueLockOrDispatchActivity($jobManager);
336 + self::assertSame(0, $this->invokeRunAsyncBatch($runner));
337 + self::assertSame(1, $jobExecutor->acquireCalls);
338 + self::assertSame(1, $jobExecutor->releaseCalls);
339 + self::assertSame([120], $jobManager->timeouts);
186 340 }
187 341
188 342 private function newRunner(
189 343 ?FakeJobManager $jobManager = null,
190 - ?FakeHttpBlockService $httpBlockService = null
344 + ?FakeHttpBlockService $httpBlockService = null,
345 + $jobExecutor = null
191 346 ): JobsRunner {
192 347 return new JobsRunner(
193 348 $jobManager ?? new FakeJobManager(),
349 + $jobExecutor ?? new FakeJobExecutor(),
194 350 new \stdClass(),
195 - new \stdClass(),
196 351 $httpBlockService ?? new FakeHttpBlockService(false)
197 352 );
198 353 }
199 354
355 + private function setLease(string $name, string $format, int $leaseOffset, ?int $timeoutOffset): void
356 + {
357 + $value = in_array($format, ['deadline', 'deadline_string'], true) ? time() + $leaseOffset : 1;
358 + if (in_array($format, ['legacy_string', 'deadline_string'], true)) $value = (string) $value;
359 + $GLOBALS['sync_basalam_jobs_runner_test_state']['transients'][$name] = $value;
360 + if ($timeoutOffset !== null) {
361 + $GLOBALS['sync_basalam_test_options']['_transient_timeout_' . $name] = (string) (time() + $timeoutOffset);
362 + }
363 + }
364 +
365 + private function invokeRunAsyncBatch(JobsRunner $runner): int
366 + {
367 + $method = new \ReflectionMethod($runner, 'runAsyncBatch');
368 + $method->setAccessible(true);
369 +
370 + return $method->invoke($runner);
371 + }
372 +
200 373 private function assertNoQueueLockOrDispatchActivity(FakeJobManager $jobManager): void
201 374 {
202 375 self::assertSame([], $jobManager->timeouts);
203 376 self::assertSame([], $this->state()['transient_reads']);
@@ -213,8 +386,9 @@
213 386
214 387 class FakeJobManager
215 388 {
216 389 public $timeouts = [];
390 + public $onProbe;
217 391
218 392 private $pendingResults;
219 393
220 394 public function __construct(array $pendingResults = [])
@@ -225,13 +399,32 @@
225 399 public function hasPendingOrStaleProcessingJobs(int $timeout): bool
226 400 {
227 401 $GLOBALS['sync_basalam_jobs_runner_test_state']['events'][] = 'has_pending_jobs';
228 402 $this->timeouts[] = $timeout;
403 + if ($this->onProbe) ($this->onProbe)();
229 404
230 405 return (bool) array_shift($this->pendingResults);
231 406 }
232 407 }
233 408
409 +class FakeWpdb
410 +{
411 + public $allowBailValues = [];
412 +
413 + public function flush(): void
414 + {
415 + $GLOBALS['sync_basalam_jobs_runner_test_state']['events'][] = 'db_flush';
416 + }
417 +
418 + public function check_connection($allowBail = true): bool
419 + {
420 + $GLOBALS['sync_basalam_jobs_runner_test_state']['events'][] = 'db_check_connection';
421 + $this->allowBailValues[] = $allowBail;
422 +
423 + return true;
424 + }
425 +}
426 +
234 427 class FakeHttpBlockService
235 428 {
236 429 public $calls = 0;
237 430
@@ -246,6 +439,33 @@
246 439 {
247 440 $this->calls++;
248 441
249 442 return $this->blocked;
443 + }
444 +}
445 +
446 +class FakeJobExecutor
447 +{
448 + public $acquireCalls = 0;
449 + public $releaseCalls = 0;
450 +
451 + private $canAcquire;
452 +
453 + public function __construct(bool $canAcquire = true)
454 + {
455 + $this->canAcquire = $canAcquire;
456 + }
457 +
458 + public function acquireGlobalJobsLock(int $timeout = 0): bool
459 + {
460 + $this->acquireCalls++;
461 +
462 + return $this->canAcquire;
463 + }
464 +
465 + public function releaseGlobalJobsLock(): bool
466 + {
467 + $this->releaseCalls++;
468 +
469 + return true;
250 470 }
251 471 }