PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / class-mlsimport-import-task-execution.php

class-mlsimport-import-task-execution.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2.1, at includes/class-mlsimport-import-task-execution.php

490 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared Import Task execution module.
4 *
5 * Manual actions, the setup wizard, and hourly cron will enter through this
6 * class. The class owns the Import Run lifecycle while WordPress persistence,
7 * the external MLS API, and theme-specific listing writes remain behind the
8 * environment boundary.
9 *
10 * The module is being built in tested vertical slices. The first slice accepts
11 * one manual run and completes the valid zero-listing case through the public
12 * start() and execute() operations.
13 *
14 * @package MLSImport
15 */
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 require_once __DIR__ . '/interface-mlsimport-import-task-execution-environment.php';
22
23 /**
24 * Coordinates one Import Run through the accepted public seam.
25 */
26 final class Mlsimport_Import_Task_Execution {
27
28 /** Thirty minutes without activity makes an abandoned run replaceable. */
29 private const STALE_AFTER_SECONDS = 1800;
30
31 /** Number of listings released together after each external request. */
32 private const BATCH_SIZE = 25;
33
34 /** Maximum listings handled by one Import Run. */
35 private const MAX_LISTINGS = 10000;
36
37 /**
38 * Wall-clock seconds one manual worker may spend before handing off.
39 *
40 * Strict hosts kill long web requests at limits the plugin cannot see
41 * (issue #199). Checked after every batch, so the worst request length is
42 * this budget plus one final batch — safely inside a 60-second kill limit.
43 */
44 private const CHUNK_BUDGET_SECONDS = 45;
45
46 /**
47 * Seconds of worker silence before the watchdog revives a manual run.
48 *
49 * A live worker records activity after every listing, so this long a
50 * silence means the worker chain died: a chunk was killed before it could
51 * queue its follow-up, or a queued worker was never dispatched.
52 */
53 private const REVIVE_AFTER_SECONDS = 90;
54
55 /**
56 * Consecutive revivals at one unmoved position before the run fails.
57 *
58 * Progress between revivals resets the count: a run that keeps dying but
59 * keeps advancing is allowed to grind to completion. Only a position the
60 * server kills repeatedly is hopeless, and retrying it forever would loop.
61 */
62 private const MAX_STALLED_REVIVALS = 3;
63
64 /** @var Mlsimport_Import_Task_Execution_Environment External operations. */
65 private $environment;
66
67 /**
68 * Receive the WordPress environment or a system-boundary test double.
69 *
70 * @param Mlsimport_Import_Task_Execution_Environment $environment External operations.
71 */
72 public function __construct( Mlsimport_Import_Task_Execution_Environment $environment ) {
73 $this->environment = $environment;
74 }
75
76 /**
77 * Request the one site-wide slot for a new Import Run.
78 *
79 * The accepted run is stored in waiting state so an HTTP manual caller can
80 * return before Action Scheduler invokes execute(). The request is stored
81 * with the run because background execution must use exactly the values the
82 * caller supplied.
83 *
84 * @param array<string, mixed> $request Task id, source, found count, and limit.
85 * @return array<string, bool|string> Public start response.
86 */
87 public function start( array $request ): array {
88 $run_id = $this->environment->new_run_id();
89 $now = $this->environment->now();
90 $run = array(
91 'run_id' => $run_id,
92 'task_id' => (int) ( $request['task_id'] ?? 0 ),
93 'source' => (string) ( $request['source'] ?? 'manual' ),
94 'state' => 'waiting',
95 'request' => $request,
96 'started_at' => $now,
97 'activity_at' => $now,
98 );
99
100 $stale_before = $now - self::STALE_AFTER_SECONDS;
101 if ( ! $this->environment->claim_run( $run, $stale_before ) ) {
102 return array(
103 'accepted' => false,
104 'reason' => 'already_running',
105 );
106 }
107
108 return array(
109 'accepted' => true,
110 'run_id' => $run_id,
111 'state' => 'waiting',
112 );
113 }
114
115 /**
116 * Request that the active Import Run for one task stop safely.
117 *
118 * Stop is final: storage records the stopped status and releases the
119 * site-wide slot immediately, so a new import may start right away.
120 * Execution still checks the persisted request before each listing, so a
121 * listing already inside the theme-specific writer may finish, while no
122 * new listing starts afterward and the stopped status is never overwritten.
123 *
124 * @param int $task_id Import Task identifier.
125 * @return array<string, bool> Whether an active matching run was found.
126 */
127 public function stop( int $task_id ): array {
128 return array( 'accepted' => $this->environment->request_stop( $task_id ) );
129 }
130
131 /**
132 * Revive a manual Import Run whose worker chain went silent (issue #199).
133 *
134 * Chunked execution depends on each worker queueing its follow-up. When a
135 * host kills a worker before that hand-off, the chain is dead and the run
136 * would sit unfinished. The watchdog is called from the polled admin
137 * progress endpoint and from hourly cron; it queues a replacement worker
138 * once activity has been silent long enough, and fails the run with an
139 * explicit hosting error when revivals at one position keep dying.
140 *
141 * The worker generation advances with every revival: a presumed-dead
142 * worker that is actually still alive sees the newer generation at its
143 * next listing boundary and exits, so one run never has two live writers.
144 *
145 * @param int $task_id Import Task identifier.
146 * @return array<string, bool|string> Whether a worker was queued, with reason.
147 */
148 public function revive( int $task_id ): array {
149 $run = $this->environment->read_active_run( $task_id );
150 if ( empty( $run ) ) {
151 return array(
152 'revived' => false,
153 'reason' => 'no_active_run',
154 );
155 }
156 // Automatic runs chunk too since issue #330, and a resumed chunk reuses
157 // the count persisted on the run record, so every source is revived.
158 $now = $this->environment->now();
159 if ( $now - (int) ( $run['activity_at'] ?? 0 ) < self::REVIVE_AFTER_SECONDS ) {
160 return array(
161 'revived' => false,
162 'reason' => 'recent_activity',
163 );
164 }
165
166 $run_id = (string) $run['run_id'];
167 $position = max( 0, (int) ( $run['handled'] ?? 0 ) );
168 // Progress since the last revival proves the run is advancing, so the
169 // stall count restarts; the same position again means another death
170 // with zero progress.
171 $stalled_revivals = $position === (int) ( $run['revived_at_position'] ?? -1 )
172 ? (int) ( $run['revive_count'] ?? 0 ) + 1
173 : 1;
174 if ( $stalled_revivals >= self::MAX_STALLED_REVIVALS ) {
175 $this->environment->finish_run(
176 $run_id,
177 array(
178 'state' => 'failed',
179 'found' => (int) ( $run['expected'] ?? 0 ),
180 'saved' => (int) ( $run['saved'] ?? 0 ),
181 'failed' => (int) ( $run['failed'] ?? 0 ),
182 'error' => sprintf(
183 'Import stopped at listing %1$d of %2$d: the server terminated the import worker %3$d times at this position. Ask your hosting provider about PHP execution limits.',
184 $position,
185 (int) ( $run['expected'] ?? 0 ),
186 self::MAX_STALLED_REVIVALS
187 ),
188 )
189 );
190 return array(
191 'revived' => false,
192 'reason' => 'stalled',
193 );
194 }
195
196 // Refreshing activity_at here also arms a fresh 90-second window, so
197 // repeated watchdog calls cannot queue a second replacement while the
198 // first is still dispatching.
199 $this->environment->update_run(
200 $run_id,
201 array(
202 'worker_generation' => (int) ( $run['worker_generation'] ?? 0 ) + 1,
203 'revive_count' => $stalled_revivals,
204 'revived_at_position' => $position,
205 'activity_at' => $now,
206 )
207 );
208 $this->environment->revive_worker( $run_id );
209 return array(
210 'revived' => true,
211 'reason' => '',
212 );
213 }
214
215 /**
216 * Return the latest administrator-visible progress and final result.
217 *
218 * Storage retains the completed, stopped, or failed result until start()
219 * accepts a later run for the same Import Task.
220 *
221 * @param int $task_id Import Task identifier.
222 * @return array<string, mixed> Public Import Run Progress and Result.
223 */
224 public function status( int $task_id ): array {
225 return $this->environment->read_task_status( $task_id );
226 }
227
228 /**
229 * Execute an accepted Import Run and return its final public result.
230 *
231 * The first vertical slice completes only the zero-listing path. A non-zero
232 * run deliberately fails fast until the next red test introduces external
233 * batch fetching and listing saves.
234 *
235 * @param string $run_id Accepted run identity.
236 * @return array<string, int|string> Final Import Run Result.
237 */
238 public function execute( string $run_id ): array {
239 $run = $this->environment->read_run( $run_id );
240 if ( empty( $run ) ) {
241 throw new InvalidArgumentException( 'Import Run was not found.' );
242 }
243 $request = isset( $run['request'] ) && is_array( $run['request'] ) ? $run['request'] : array();
244 $source = (string) ( $run['source'] ?? 'manual' );
245 // A resumed chunk reuses the plan persisted by the first worker: the
246 // count is taken once per run (issue #330). Listings keep changing
247 // during a long import, so counting again mid-run would move the
248 // goal posts and end the run early or late.
249 $found = max( 0, (int) ( $run['found'] ?? ( $request['found'] ?? 0 ) ) );
250
251 // A stale worker may wake after a replacement claimed the site-wide
252 // slot. It must stop before changing state, fetching, or saving.
253 if ( ! $this->environment->owns_run( $run_id ) ) {
254 $result = array(
255 'state' => 'stopped',
256 'found' => $found,
257 'saved' => 0,
258 'failed' => 0,
259 'error' => 'Import Run was replaced.',
260 );
261 $this->environment->finish_run( $run_id, $result );
262 return $result;
263 }
264
265 // Automatic runs count changed listings at execution time, on the first
266 // worker only (a resumed chunk carries the count on the run record).
267 // Manual runs intentionally retain the count already shown on the task
268 // screen.
269 if ( 'automatic' === $source && ! isset( $run['found'] ) ) {
270 try {
271 $count_response = $this->environment->count_listings( $run );
272 } catch ( Throwable $exception ) {
273 $count_response = array(
274 'success' => false,
275 'error' => $exception->getMessage(),
276 );
277 }
278 if ( true !== ( $count_response['success'] ?? false ) || ! isset( $count_response['found'] ) ) {
279 $result = array(
280 'state' => 'failed',
281 'found' => 0,
282 'saved' => 0,
283 'failed' => 0,
284 'error' => (string) ( $count_response['error'] ?? 'Listings count failed.' ),
285 );
286 $this->environment->finish_run( $run_id, $result );
287 return $result;
288 }
289 $found = max( 0, (int) $count_response['found'] );
290 if ( $found > self::MAX_LISTINGS ) {
291 $result = array(
292 'state' => 'failed',
293 'found' => $found,
294 'saved' => 0,
295 'failed' => 0,
296 'error' => 'Automatic Import Run exceeds the 10,000 listing limit.',
297 );
298 $this->environment->finish_run( $run_id, $result );
299 return $result;
300 }
301 }
302
303 $limit = max( 0, (int) ( $request['limit'] ?? 0 ) );
304 $expected = 0 === $limit ? $found : min( $found, $limit );
305 $expected = min( self::MAX_LISTINGS, $expected );
306
307 // A resumed chunk continues exactly where the previous worker handed
308 // off: position and totals come from the persisted run, so a fresh run
309 // starts at zero and a continuation never repeats or skips a listing.
310 $saved = max( 0, (int) ( $run['saved'] ?? 0 ) );
311 $failed = max( 0, (int) ( $run['failed'] ?? 0 ) );
312 $handled = max( 0, (int) ( $run['handled'] ?? 0 ) );
313 $error = (string) ( $run['error'] ?? '' );
314 $this->environment->update_run(
315 $run_id,
316 array(
317 'state' => 'running',
318 'found' => $found,
319 'handled' => $handled,
320 'expected' => $expected,
321 'error' => $error,
322 'activity_at' => $this->environment->now(),
323 )
324 );
325 // A Stop request can arrive while an async manual run is still waiting.
326 // Honor it before the first external request; during a listing save, the
327 // existing per-listing check lets that current save finish safely.
328 $stopped = $this->environment->stop_requested( $run_id );
329 $chunk_started_at = $this->environment->now();
330 // The generation this worker was started for. A watchdog revival
331 // advances the stored generation, so an overtaken worker — presumed
332 // dead but actually alive — recognizes its replacement per listing.
333 $generation = (int) ( $run['worker_generation'] ?? 0 );
334 while ( $handled < $expected ) {
335 if ( $stopped ) {
336 break;
337 }
338 $batch_limit = min( self::BATCH_SIZE, $expected - $handled );
339 try {
340 $response = $this->environment->fetch_listing_batch( $run, $handled, $batch_limit );
341 } catch ( Throwable $exception ) {
342 $error = '' !== $exception->getMessage() ? $exception->getMessage() : 'Listings request failed.';
343 break;
344 }
345 $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
346
347 // A failed response is a real error: continuing would silently
348 // leave a hole in the import.
349 if ( true !== ( $response['success'] ?? false ) ) {
350 $error = (string) ( $response['error'] ?? 'Listings request failed.' );
351 break;
352 }
353
354 // A valid but EMPTY page is not an error: the MLS holds fewer
355 // matching listings than were counted when the run started
356 // (listings change status or vanish during a long import — observed
357 // as 1037 real listings for a count of 1039). Everything available
358 // has been imported, so finish the run normally.
359 if ( empty( $data ) ) {
360 break;
361 }
362
363 $remaining = $expected - $handled;
364 foreach ( array_slice( $data, 0, $remaining ) as $listing ) {
365 // A final Stop releases the site-wide slot immediately, so an
366 // explicit Stop must be recognized before the lost slot is
367 // treated as a replacement by another run.
368 if ( $this->environment->stop_requested( $run_id ) ) {
369 $stopped = true;
370 break;
371 }
372 if ( ! $this->environment->owns_run( $run_id ) ) {
373 $stopped = true;
374 $error = 'Import Run was replaced.';
375 break;
376 }
377 // A revival replaced this worker while it was presumed dead.
378 // Unlike the lost-slot case above, the run itself is still
379 // alive under the replacement worker, so this worker must
380 // leave the run record, the slot, and the task status alone —
381 // it exits without finishing anything.
382 if ( (int) ( $this->environment->read_run( $run_id )['worker_generation'] ?? 0 ) !== $generation ) {
383 return array(
384 'state' => 'stopped',
385 'found' => $found,
386 'saved' => $saved,
387 'failed' => $failed,
388 'error' => 'Import Run was replaced.',
389 );
390 }
391 ++$handled;
392 if ( ! is_array( $listing ) || empty( $listing['ListingKey'] ) ) {
393 ++$failed;
394 $error = 'ListingKey is missing.';
395 } else {
396 try {
397 $save = $this->environment->save_listing( $run, $listing );
398 } catch ( Throwable $exception ) {
399 $save = array(
400 'success' => false,
401 'error' => '' !== $exception->getMessage()
402 ? $exception->getMessage()
403 : 'Listing could not be saved.',
404 );
405 }
406 if ( true === ( $save['success'] ?? false ) ) {
407 ++$saved;
408 } else {
409 ++$failed;
410 $error = (string) ( $save['error'] ?? 'Listing could not be saved.' );
411 }
412 }
413
414 // Persist progress after every listing so the polled admin
415 // progress bar advances in near real time, not once per batch.
416 // Saved and failed totals are stored too, so a later chunk or a
417 // watchdog revival can continue with correct final counts.
418 $this->environment->update_run(
419 $run_id,
420 array(
421 'handled' => $handled,
422 'expected' => $expected,
423 'saved' => $saved,
424 'failed' => $failed,
425 'error' => $error,
426 'activity_at' => $this->environment->now(),
427 )
428 );
429 }
430
431 if ( $stopped ) {
432 break;
433 }
434
435 // Resumable chunking (issue #199): a worker whose time budget is
436 // spent must not start another batch inside this same request —
437 // strict hosts kill long requests at limits the plugin cannot see.
438 // Position and totals were persisted with the last listing, so this
439 // worker queues a follow-up worker for the same run, keeps the
440 // site-wide slot, and exits. Only a worker that reaches the end of
441 // the plan finishes the run below. Automatic runs chunk too (issue
442 // #330): an hourly delta the size of a whole task used to run in
443 // one cron request, die, and restart from zero every hour.
444 if ( $handled < $expected
445 && ( $this->environment->now() - $chunk_started_at ) >= self::CHUNK_BUDGET_SECONDS ) {
446 // Count the hand-off on the run record (issue #216): the finished
447 // run's telemetry snapshot reports 1 + handoffs + revivals as its
448 // worker total. Each worker hands off at most once, so the value
449 // read at execute() start is still current here.
450 $this->environment->update_run(
451 $run_id,
452 array( 'handoffs' => (int) ( $run['handoffs'] ?? 0 ) + 1 )
453 );
454 $this->environment->enqueue_worker( $run_id );
455 return array(
456 'state' => 'running',
457 'found' => $found,
458 'saved' => $saved,
459 'failed' => $failed,
460 'error' => $error,
461 );
462 }
463 }
464
465 $state = $stopped ? 'stopped' : ( '' === $error && 0 === $failed ? 'completed' : 'failed' );
466 $result = array(
467 'state' => $state,
468 'found' => $found,
469 'saved' => $saved,
470 'failed' => $failed,
471 'error' => $error,
472 );
473 // Every completed run advances the task's last-sync watermark — not just
474 // automatic ones (GitHub issue #202 follow-up). A completed manual run
475 // has just written everything the task matches, so "changed since
476 // completion" is exactly the right next window; and it is the manual run
477 // that makes a task cron-eligible, so it must seed the watermark the
478 // hourly sync requires (an automatic run refuses to start without one).
479 if ( 'completed' === $state ) {
480 $this->environment->advance_last_successful_sync_time(
481 (int) $run['task_id'],
482 $this->environment->now()
483 );
484 }
485 $this->environment->finish_run( $run_id, $result );
486
487 return $result;
488 }
489 }
490