PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.2
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.1.2, at includes/class-mlsimport-import-task-execution.php

488 lines 17.2 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 // Scope agreed for issue #199: only manual runs chunk, so only manual
157 // runs are revived. A resumed automatic run would recount mid-run.
158 if ( 'manual' !== (string) ( $run['source'] ?? '' ) ) {
159 return array(
160 'revived' => false,
161 'reason' => 'not_manual',
162 );
163 }
164 $now = $this->environment->now();
165 if ( $now - (int) ( $run['activity_at'] ?? 0 ) < self::REVIVE_AFTER_SECONDS ) {
166 return array(
167 'revived' => false,
168 'reason' => 'recent_activity',
169 );
170 }
171
172 $run_id = (string) $run['run_id'];
173 $position = max( 0, (int) ( $run['handled'] ?? 0 ) );
174 // Progress since the last revival proves the run is advancing, so the
175 // stall count restarts; the same position again means another death
176 // with zero progress.
177 $stalled_revivals = $position === (int) ( $run['revived_at_position'] ?? -1 )
178 ? (int) ( $run['revive_count'] ?? 0 ) + 1
179 : 1;
180 if ( $stalled_revivals >= self::MAX_STALLED_REVIVALS ) {
181 $this->environment->finish_run(
182 $run_id,
183 array(
184 'state' => 'failed',
185 'found' => (int) ( $run['expected'] ?? 0 ),
186 'saved' => (int) ( $run['saved'] ?? 0 ),
187 'failed' => (int) ( $run['failed'] ?? 0 ),
188 'error' => sprintf(
189 '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.',
190 $position,
191 (int) ( $run['expected'] ?? 0 ),
192 self::MAX_STALLED_REVIVALS
193 ),
194 )
195 );
196 return array(
197 'revived' => false,
198 'reason' => 'stalled',
199 );
200 }
201
202 // Refreshing activity_at here also arms a fresh 90-second window, so
203 // repeated watchdog calls cannot queue a second replacement while the
204 // first is still dispatching.
205 $this->environment->update_run(
206 $run_id,
207 array(
208 'worker_generation' => (int) ( $run['worker_generation'] ?? 0 ) + 1,
209 'revive_count' => $stalled_revivals,
210 'revived_at_position' => $position,
211 'activity_at' => $now,
212 )
213 );
214 $this->environment->revive_worker( $run_id );
215 return array(
216 'revived' => true,
217 'reason' => '',
218 );
219 }
220
221 /**
222 * Return the latest administrator-visible progress and final result.
223 *
224 * Storage retains the completed, stopped, or failed result until start()
225 * accepts a later run for the same Import Task.
226 *
227 * @param int $task_id Import Task identifier.
228 * @return array<string, mixed> Public Import Run Progress and Result.
229 */
230 public function status( int $task_id ): array {
231 return $this->environment->read_task_status( $task_id );
232 }
233
234 /**
235 * Execute an accepted Import Run and return its final public result.
236 *
237 * The first vertical slice completes only the zero-listing path. A non-zero
238 * run deliberately fails fast until the next red test introduces external
239 * batch fetching and listing saves.
240 *
241 * @param string $run_id Accepted run identity.
242 * @return array<string, int|string> Final Import Run Result.
243 */
244 public function execute( string $run_id ): array {
245 $run = $this->environment->read_run( $run_id );
246 if ( empty( $run ) ) {
247 throw new InvalidArgumentException( 'Import Run was not found.' );
248 }
249 $request = isset( $run['request'] ) && is_array( $run['request'] ) ? $run['request'] : array();
250 $source = (string) ( $run['source'] ?? 'manual' );
251 $found = max( 0, (int) ( $request['found'] ?? 0 ) );
252
253 // A stale worker may wake after a replacement claimed the site-wide
254 // slot. It must stop before changing state, fetching, or saving.
255 if ( ! $this->environment->owns_run( $run_id ) ) {
256 $result = array(
257 'state' => 'stopped',
258 'found' => $found,
259 'saved' => 0,
260 'failed' => 0,
261 'error' => 'Import Run was replaced.',
262 );
263 $this->environment->finish_run( $run_id, $result );
264 return $result;
265 }
266
267 // Automatic runs count changed listings at execution time. Manual runs
268 // intentionally retain the count already shown on the task screen.
269 if ( 'automatic' === $source ) {
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 'handled' => $handled,
319 'expected' => $expected,
320 'error' => $error,
321 'activity_at' => $this->environment->now(),
322 )
323 );
324 // A Stop request can arrive while an async manual run is still waiting.
325 // Honor it before the first external request; during a listing save, the
326 // existing per-listing check lets that current save finish safely.
327 $stopped = $this->environment->stop_requested( $run_id );
328 $chunk_started_at = $this->environment->now();
329 // The generation this worker was started for. A watchdog revival
330 // advances the stored generation, so an overtaken worker — presumed
331 // dead but actually alive — recognizes its replacement per listing.
332 $generation = (int) ( $run['worker_generation'] ?? 0 );
333 while ( $handled < $expected ) {
334 if ( $stopped ) {
335 break;
336 }
337 $batch_limit = min( self::BATCH_SIZE, $expected - $handled );
338 try {
339 $response = $this->environment->fetch_listing_batch( $run, $handled, $batch_limit );
340 } catch ( Throwable $exception ) {
341 $error = '' !== $exception->getMessage() ? $exception->getMessage() : 'Listings request failed.';
342 break;
343 }
344 $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
345
346 // A failed response is a real error: continuing would silently
347 // leave a hole in the import.
348 if ( true !== ( $response['success'] ?? false ) ) {
349 $error = (string) ( $response['error'] ?? 'Listings request failed.' );
350 break;
351 }
352
353 // A valid but EMPTY page is not an error: the MLS holds fewer
354 // matching listings than were counted when the run started
355 // (listings change status or vanish during a long import — observed
356 // as 1037 real listings for a count of 1039). Everything available
357 // has been imported, so finish the run normally.
358 if ( empty( $data ) ) {
359 break;
360 }
361
362 $remaining = $expected - $handled;
363 foreach ( array_slice( $data, 0, $remaining ) as $listing ) {
364 // A final Stop releases the site-wide slot immediately, so an
365 // explicit Stop must be recognized before the lost slot is
366 // treated as a replacement by another run.
367 if ( $this->environment->stop_requested( $run_id ) ) {
368 $stopped = true;
369 break;
370 }
371 if ( ! $this->environment->owns_run( $run_id ) ) {
372 $stopped = true;
373 $error = 'Import Run was replaced.';
374 break;
375 }
376 // A revival replaced this worker while it was presumed dead.
377 // Unlike the lost-slot case above, the run itself is still
378 // alive under the replacement worker, so this worker must
379 // leave the run record, the slot, and the task status alone —
380 // it exits without finishing anything.
381 if ( (int) ( $this->environment->read_run( $run_id )['worker_generation'] ?? 0 ) !== $generation ) {
382 return array(
383 'state' => 'stopped',
384 'found' => $found,
385 'saved' => $saved,
386 'failed' => $failed,
387 'error' => 'Import Run was replaced.',
388 );
389 }
390 ++$handled;
391 if ( ! is_array( $listing ) || empty( $listing['ListingKey'] ) ) {
392 ++$failed;
393 $error = 'ListingKey is missing.';
394 } else {
395 try {
396 $save = $this->environment->save_listing( $run, $listing );
397 } catch ( Throwable $exception ) {
398 $save = array(
399 'success' => false,
400 'error' => '' !== $exception->getMessage()
401 ? $exception->getMessage()
402 : 'Listing could not be saved.',
403 );
404 }
405 if ( true === ( $save['success'] ?? false ) ) {
406 ++$saved;
407 } else {
408 ++$failed;
409 $error = (string) ( $save['error'] ?? 'Listing could not be saved.' );
410 }
411 }
412
413 // Persist progress after every listing so the polled admin
414 // progress bar advances in near real time, not once per batch.
415 // Saved and failed totals are stored too, so a later chunk or a
416 // watchdog revival can continue with correct final counts.
417 $this->environment->update_run(
418 $run_id,
419 array(
420 'handled' => $handled,
421 'expected' => $expected,
422 'saved' => $saved,
423 'failed' => $failed,
424 'error' => $error,
425 'activity_at' => $this->environment->now(),
426 )
427 );
428 }
429
430 if ( $stopped ) {
431 break;
432 }
433
434 // Resumable chunking (issue #199): a manual worker whose time budget
435 // is spent must not start another batch inside this same request —
436 // strict hosts kill long requests at limits the plugin cannot see.
437 // Position and totals were persisted with the last listing, so this
438 // worker queues a follow-up worker for the same run, keeps the
439 // site-wide slot, and exits. Only a worker that reaches the end of
440 // the plan finishes the run below.
441 if ( 'manual' === $source
442 && $handled < $expected
443 && ( $this->environment->now() - $chunk_started_at ) >= self::CHUNK_BUDGET_SECONDS ) {
444 // Count the hand-off on the run record (issue #216): the finished
445 // run's telemetry snapshot reports 1 + handoffs + revivals as its
446 // worker total. Each worker hands off at most once, so the value
447 // read at execute() start is still current here.
448 $this->environment->update_run(
449 $run_id,
450 array( 'handoffs' => (int) ( $run['handoffs'] ?? 0 ) + 1 )
451 );
452 $this->environment->enqueue_worker( $run_id );
453 return array(
454 'state' => 'running',
455 'found' => $found,
456 'saved' => $saved,
457 'failed' => $failed,
458 'error' => $error,
459 );
460 }
461 }
462
463 $state = $stopped ? 'stopped' : ( '' === $error && 0 === $failed ? 'completed' : 'failed' );
464 $result = array(
465 'state' => $state,
466 'found' => $found,
467 'saved' => $saved,
468 'failed' => $failed,
469 'error' => $error,
470 );
471 // Every completed run advances the task's last-sync watermark — not just
472 // automatic ones (GitHub issue #202 follow-up). A completed manual run
473 // has just written everything the task matches, so "changed since
474 // completion" is exactly the right next window; and it is the manual run
475 // that makes a task cron-eligible, so it must seed the watermark the
476 // hourly sync requires (an automatic run refuses to start without one).
477 if ( 'completed' === $state ) {
478 $this->environment->advance_last_successful_sync_time(
479 (int) $run['task_id'],
480 $this->environment->now()
481 );
482 }
483 $this->environment->finish_run( $run_id, $result );
484
485 return $result;
486 }
487 }
488