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-wordpress-environment.php

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

619 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress storage and external operations for Import Task execution.
4 *
5 * The execution module decides what an Import Run does. This class translates
6 * those decisions into WordPress options, post meta, MLS requests, and the
7 * existing theme-specific listing writer.
8 *
9 * @package MLSImport
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 require_once __DIR__ . '/interface-mlsimport-import-task-execution-environment.php';
17
18 /**
19 * Connects the shared Import Task runner to WordPress.
20 */
21 final class Mlsimport_Import_Task_Execution_WordPress_Environment implements Mlsimport_Import_Task_Execution_Environment {
22
23 /** The small site-wide record that prevents two imports from overlapping. */
24 private const LOCK_OPTION = 'mlsimport_import_run_lock';
25
26 /** Latest administrator-visible progress and result for each Import Task. */
27 private const STATUS_META = 'mlsimport_import_run_status';
28
29 /** @var Mlsimport_Admin Existing admin/API controller. */
30 private $admin;
31
32 /**
33 * Receive the existing admin controller at the module boundary.
34 *
35 * @param Mlsimport_Admin $admin Existing admin/API controller.
36 */
37 public function __construct( Mlsimport_Admin $admin ) {
38 $this->admin = $admin;
39 }
40
41 /** {@inheritDoc} */
42 public function new_run_id(): string {
43 return wp_generate_uuid4();
44 }
45
46 /** {@inheritDoc} */
47 public function now(): int {
48 return time();
49 }
50
51 /** {@inheritDoc} */
52 public function claim_run( array $run, int $stale_before ): bool {
53 $run_id = (string) $run['run_id'];
54 update_option( $this->run_option_name( $run_id ), $run, false );
55
56 $lock = $this->lock_from_run( $run );
57 if ( ! add_option( self::LOCK_OPTION, $lock, '', false ) ) {
58 $current = get_option( self::LOCK_OPTION, array() );
59 if ( ! is_array( $current ) || (int) ( $current['activity_at'] ?? 0 ) > $stale_before ) {
60 delete_option( $this->run_option_name( $run_id ) );
61 return false;
62 }
63 if ( ! $this->replace_stale_lock( $current, $lock ) ) {
64 delete_option( $this->run_option_name( $run_id ) );
65 return false;
66 }
67 }
68
69 $this->write_task_status( $run, array() );
70 return true;
71 }
72
73 /** {@inheritDoc} */
74 public function read_run( string $run_id ): array {
75 $run = get_option( $this->run_option_name( $run_id ), array() );
76 return is_array( $run ) ? $run : array();
77 }
78
79 /** {@inheritDoc} */
80 public function owns_run( string $run_id ): bool {
81 $lock = get_option( self::LOCK_OPTION, array() );
82 return is_array( $lock ) && hash_equals( (string) ( $lock['run_id'] ?? '' ), $run_id );
83 }
84
85 /** {@inheritDoc} */
86 public function update_run( string $run_id, array $changes ): void {
87 $run = array_merge( $this->read_run( $run_id ), $changes );
88 update_option( $this->run_option_name( $run_id ), $run, false );
89 if ( ! $this->owns_run( $run_id ) ) {
90 return;
91 }
92
93 update_option( self::LOCK_OPTION, $this->lock_from_run( $run ), false );
94 $this->write_task_status( $run, array() );
95 }
96
97 /** {@inheritDoc} */
98 public function finish_run( string $run_id, array $result ): void {
99 $run = array_merge(
100 $this->read_run( $run_id ),
101 array(
102 'state' => (string) $result['state'],
103 'activity_at' => $this->now(),
104 'result' => $result,
105 )
106 );
107 update_option( $this->run_option_name( $run_id ), $run, false );
108
109 // A replaced worker must not overwrite the newer run's task status or
110 // release the newer worker's lock.
111 if ( $this->owns_run( $run_id ) ) {
112 // Import-performance telemetry (issue #216): the owning finisher —
113 // and only it, so a replaced zombie cannot overwrite the real
114 // numbers — records the snapshot the daily heartbeat ships and the
115 // task screen shows. All inputs already live on the run record.
116 if ( function_exists( 'mlsimport_telemetry_import_run_snapshot' ) ) {
117 mlsimport_telemetry_set(
118 'last_import_run',
119 mlsimport_telemetry_import_run_snapshot(
120 $run,
121 $result,
122 $this->now(),
123 memory_get_peak_usage( true ),
124 $this->pending_worker_actions()
125 )
126 );
127 }
128 $this->write_task_status( $run, $result );
129 delete_option( self::LOCK_OPTION );
130 }
131 delete_option( $this->run_option_name( $run_id ) );
132 }
133
134 /**
135 * Count import worker actions still pending in Action Scheduler.
136 *
137 * Queue-depth evidence for the telemetry snapshot: a healthy finish leaves
138 * zero pending workers, while a growing number means enqueued work is not
139 * being dispatched on this host.
140 *
141 * @return int Pending 'mlsimport_background_process_per_item' actions.
142 */
143 private function pending_worker_actions(): int {
144 if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
145 return 0;
146 }
147 $pending = as_get_scheduled_actions(
148 array(
149 'hook' => 'mlsimport_background_process_per_item',
150 'status' => ActionScheduler_Store::STATUS_PENDING,
151 'per_page' => -1,
152 ),
153 'ids'
154 );
155 return is_array( $pending ) ? count( $pending ) : 0;
156 }
157
158 /**
159 * Fetch one listing group after rejecting an unsupported Stored adapter.
160 *
161 * Provider arguments and the external SaaS call retain their existing admin
162 * boundaries. The adapter configuration check runs first so no listing data
163 * is requested when the site cannot persist it safely. A failed SaaS call is
164 * retried twice (5s pause) so one transient timeout cannot abort a long run.
165 *
166 * @param array<string, mixed> $run Current Import Run.
167 * @param int $skip Zero-based listing offset.
168 * @param int $limit Maximum listings requested.
169 * @return array<string, mixed> Success/data or failure/error response.
170 */
171 public function fetch_listing_batch( array $run, int $skip, int $limit ): array {
172 $configuration_error = $this->admin->mlsimport_stored_listing_configuration_error();
173 if ( '' !== $configuration_error ) {
174 return array( 'success' => false, 'error' => $configuration_error );
175 }
176
177 $request = is_array( $run['request'] ?? null ) ? $run['request'] : array();
178 $automatic = 'automatic' === (string) ( $run['source'] ?? '' );
179 $last_date = $automatic ? get_post_meta( (int) $run['task_id'], 'mlsimport_last_date', true ) : '';
180 $arguments = $this->admin->mlsimport_saas_make_listing_requests_arguments(
181 (int) $run['task_id'],
182 (string) ( $request['last_date'] ?? $last_date ),
183 $skip,
184 $limit,
185 $automatic
186 );
187 if ( ! is_array( $arguments ) ) {
188 return array( 'success' => false, 'error' => 'Listing request could not be built.' );
189 }
190 // Provider-specific validation happens inside the Provider Family adapter.
191 // Stop before the SaaS request and expose its safe message to the Import Run.
192 if ( isset( $arguments['mlsimport_provider_error'] ) ) {
193 $error = $arguments['mlsimport_provider_error'];
194 return array(
195 'success' => false,
196 'error' => is_array( $error ) && isset( $error['message'] )
197 ? (string) $error['message']
198 : 'The MLS request could not be prepared.',
199 );
200 }
201
202 // A connection the SaaS rejected with the stable not_entitled code skips
203 // its imports until it is re-entitled (#276) — no request is sent and the
204 // Import Run surfaces the failure; other connections are unaffected.
205 if ( mlsimport_connection_not_entitled( (int) ( $arguments['mls_id'] ?? 0 ) ) ) {
206 return array(
207 'success' => false,
208 'error' => 'Your account is not entitled to this MLS. Imports for it are paused.',
209 );
210 }
211
212 // Proven-previous-version parity: breathe for 100ms between batches so
213 // the database and the SaaS API get a gap between bursts of work. The
214 // first batch of a run starts immediately.
215 if ( $skip > 0 ) {
216 usleep( 100000 );
217 }
218
219 // One transient SaaS hang (observed: a single 120s cURL timeout at batch
220 // 425/1039 while the surrounding 41 fetches took ~2.5s) must not abort a
221 // long Import Run. Retry the identical request up to twice before the
222 // failure is real; on final failure surface the transport error text
223 // (the API client returns it as a string) instead of a generic message.
224 $response = null;
225 for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
226 if ( $attempt > 1 ) {
227 // Each retry is a diagnosable event: a run that succeeds only
228 // on attempt 2 still tells the log the SaaS call hung once.
229 mlsimport_saas_single_write_import_custom_logs(
230 'Listings fetch retry ' . $attempt . '/3 at offset ' . $skip . '.' . PHP_EOL,
231 'manual'
232 );
233 sleep( 5 );
234 }
235 $fetch_started_at = microtime( true );
236 $response = $this->admin->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
237 $fetch_seconds = microtime( true ) - $fetch_started_at;
238 // A successful but slow fetch is the early warning for the
239 // transient 120s hangs observed in production-size runs.
240 if ( $fetch_seconds > 10 ) {
241 mlsimport_saas_single_write_import_custom_logs(
242 'Slow listings fetch: ' . round( $fetch_seconds, 1 ) . 's at offset ' . $skip . ' (attempt ' . $attempt . ').' . PHP_EOL,
243 'manual'
244 );
245 }
246 if ( is_array( $response ) && isset( $response['data'] ) && is_array( $response['data'] ) ) {
247 break;
248 }
249 }
250 if ( ! is_array( $response ) || ! isset( $response['data'] ) || ! is_array( $response['data'] ) ) {
251 // The server rejected this mls_id against the account's entitlements
252 // (#276): mark this one connection so the next batch/run skips it.
253 // The failure below still surfaces — never a silent fallback.
254 if ( mlsimport_response_not_entitled( $response ) ) {
255 mlsimport_mark_connection_not_entitled( (int) ( $arguments['mls_id'] ?? 0 ) );
256 }
257 $error = 'Listings request failed.';
258 if ( is_string( $response ) && '' !== $response ) {
259 $error = $response;
260 } elseif ( is_array( $response ) && '' !== (string) ( $response['message'] ?? '' ) ) {
261 $error = (string) $response['message'];
262 }
263 mlsimport_saas_single_write_import_custom_logs(
264 'Listings fetch FAILED after 3 attempts at offset ' . $skip . ': ' . $error . PHP_EOL,
265 'manual'
266 );
267 return array( 'success' => false, 'error' => $error );
268 }
269
270 return array( 'success' => true, 'data' => $response['data'] );
271 }
272
273 /**
274 * Translate live Import Task settings and save one listing through the module.
275 *
276 * The configuration hash includes every update-time choice that can require a
277 * rewrite when MLS data is unchanged. The public listing outcome is translated
278 * into the Import Run's success/error shape without hiding photo warnings.
279 *
280 * @param array<string, mixed> $run Current Import Run.
281 * @param array<string, mixed> $listing Incoming raw listing.
282 * @return array<string, mixed> Import Run save result.
283 */
284 public function save_listing( array $run, array $listing ): array {
285 $task_id = (int) $run['task_id'];
286 $user_id = (int) get_post_meta( $task_id, 'mlsimport_item_property_user', true );
287 if ( 0 === $user_id ) {
288 $user_id = (int) get_post_field( 'post_author', $task_id );
289 }
290 $title_format = (string) get_post_meta( $task_id, 'mlsimport_item_title_format', true );
291 if ( '' === $title_format ) {
292 // Per-connection sync settings (#275), resolved through the
293 // task's OWN connection binding (#277).
294 $sync_options = mlsimport_get_connection_option( 'mlsimport_admin_mls_sync', array(), mlsimport_task_mls_id( $task_id ) );
295 $title_format = is_array( $sync_options ) ? (string) ( $sync_options['title_format'] ?? '' ) : '';
296 }
297 // Field configuration for the task's OWN connection (#277) — the
298 // shared projection cache, keyed by the task's binding.
299 $field_configuration = mlsimport_active_field_configuration( false, mlsimport_task_mls_id( $task_id ) );
300 $use_mls_agent = ! empty( get_post_meta( $task_id, 'mlsimport_item_use_mls_agent', true ) );
301 $config_version = hash(
302 'sha256',
303 wp_json_encode(
304 array(
305 'user_id' => $user_id,
306 'title_format' => $title_format,
307 'field_configuration' => $field_configuration,
308 'use_mls_agent' => $use_mls_agent,
309 )
310 )
311 );
312 $options = array(
313 'mlsimport_item_standardstatus' => get_post_meta( $task_id, 'mlsimport_item_standardstatus', true ),
314 'mlsimport_item_standardstatusprotect' => get_post_meta( $task_id, 'mlsimport_item_standardstatusprotect', true ),
315 'mlsimport_item_property_user' => $user_id,
316 'mlsimport_item_agent' => get_post_meta( $task_id, 'mlsimport_item_agent', true ),
317 'mlsimport_item_use_mls_agent' => $use_mls_agent,
318 'mlsimport_item_property_status' => get_post_meta( $task_id, 'mlsimport_item_property_status', true ),
319 'mlsimport_field_configuration' => $field_configuration,
320 'mlsimport_item_title_format' => $title_format,
321 'mlsimport_write_config_version' => $config_version,
322 );
323 // Proven-previous-version parity: suspend the two heavy post-write hook
324 // stacks while this one listing is written, so third-party save handlers
325 // (SEO indexers, cache purgers, notifiers) do not run once per imported
326 // listing and per attachment. Restored in finally so a throwing writer
327 // can never leave the site with its save hooks disabled.
328 global $wp_filter;
329 $suspended_filters = array();
330 foreach ( array( 'save_post', 'transition_post_status' ) as $suspended_hook ) {
331 if ( isset( $wp_filter[ $suspended_hook ] ) ) {
332 $suspended_filters[ $suspended_hook ] = $wp_filter[ $suspended_hook ];
333 $wp_filter[ $suspended_hook ] = new WP_Hook();
334 }
335 }
336 try {
337 $saved = $this->admin->theme_importer->mlsimportSaasPrepareToImportPerItem(
338 $listing,
339 array( 'item_id' => $task_id ),
340 'automatic' === (string) ( $run['source'] ?? '' ) ? 'cron' : 'manual',
341 $options
342 );
343 } finally {
344 foreach ( $suspended_filters as $suspended_hook => $hook_object ) {
345 $wp_filter[ $suspended_hook ] = $hook_object;
346 }
347 }
348
349 // The one-listing writer is forbidden to flush site-wide caches, so the
350 // batch context must release memory after every listing. Without this,
351 // each imported property and its attachments stay in the runtime object
352 // cache until the worker dies at the PHP memory limit mid-run (observed
353 // as a fatal at 75 of 100 listings under a 256M limit). Mirror Action
354 // Scheduler's own between-actions cleanup: flush only the runtime cache
355 // when supported so an external object cache is not invalidated.
356 if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ) ) {
357 wp_cache_flush_runtime();
358 } elseif ( ! wp_using_ext_object_cache() ) {
359 wp_cache_flush();
360 }
361 // Sites running with SAVEQUERIES accumulate every query in memory; the
362 // proven cpt-mlsimport import loop cleared this each listing as well.
363 global $wpdb;
364 $wpdb->queries = array();
365 gc_collect_cycles();
366 if ( false === $saved || is_wp_error( $saved ) || 'failed' === ( $saved['outcome'] ?? '' ) ) {
367 $error = is_wp_error( $saved ) ? $saved->get_error_message() : 'Theme writer reported failure.';
368 if ( is_array( $saved ) && '' !== (string) ( $saved['error'] ?? '' ) ) {
369 $error = (string) $saved['error'];
370 }
371 // Name the exact listing: the run result only keeps the LAST error,
372 // so without this line a single bad listing among a thousand is
373 // impossible to find after the run.
374 mlsimport_saas_single_write_import_custom_logs(
375 'Listing save FAILED for ' . (string) ( $listing['ListingKey'] ?? 'unknown-key' ) . ': ' . $error . PHP_EOL,
376 'manual'
377 );
378 return array( 'success' => false, 'error' => $error );
379 }
380
381 // Photo/meta warnings do not fail the listing, so they never reach the
382 // run result — the import log is their only permanent record.
383 $warnings = is_array( $saved ) ? (array) ( $saved['warnings'] ?? array() ) : array();
384 if ( ! empty( $warnings ) ) {
385 mlsimport_saas_single_write_import_custom_logs(
386 'Listing ' . (string) ( $listing['ListingKey'] ?? 'unknown-key' ) . ' saved with warnings: '
387 . implode( ' | ', array_map( 'strval', $warnings ) ) . PHP_EOL,
388 'manual'
389 );
390 }
391 return array(
392 'success' => true,
393 'outcome' => is_array( $saved ) ? (string) ( $saved['outcome'] ?? 'updated' ) : 'updated',
394 'warnings' => $warnings,
395 );
396 }
397
398 /** {@inheritDoc} */
399 public function request_stop( int $task_id ): bool {
400 $lock = get_option( self::LOCK_OPTION, array() );
401 if ( ! is_array( $lock ) || $task_id !== (int) ( $lock['task_id'] ?? 0 ) ) {
402 return false;
403 }
404 $run_id = (string) $lock['run_id'];
405 $run = $this->read_run( $run_id );
406 $run['stop_requested'] = true;
407 update_option( $this->run_option_name( $run_id ), $run, false );
408
409 // Stop is final for the administrator: record the stopped result and
410 // release the site-wide slot right away so a new import can start
411 // immediately. A still-live worker sees stop_requested at its next
412 // listing boundary and exits without touching this status (update_run
413 // and finish_run both skip status/lock writes once the slot is gone).
414 // A dead worker can no longer hold the site locked for 30 minutes.
415 $run['state'] = 'stopped';
416 $this->write_task_status(
417 $run,
418 array(
419 'state' => 'stopped',
420 'found' => (int) ( $run['expected'] ?? 0 ),
421 // The environment only tracks handled listings; the exact
422 // saved/failed split stays with the worker and is not shown
423 // for stopped runs.
424 'saved' => (int) ( $run['handled'] ?? 0 ),
425 'failed' => 0,
426 'error' => '',
427 )
428 );
429 delete_option( self::LOCK_OPTION );
430 return true;
431 }
432
433 /** {@inheritDoc} */
434 public function stop_requested( string $run_id ): bool {
435 $run = $this->read_run( $run_id );
436 return true === ( $run['stop_requested'] ?? false );
437 }
438
439 /** {@inheritDoc} */
440 public function read_task_status( int $task_id ): array {
441 $status = get_post_meta( $task_id, self::STATUS_META, true );
442 return is_array( $status ) ? $status : array();
443 }
444
445 /**
446 * Queue the follow-up worker for a chunk hand-off (issue #199).
447 *
448 * Called from inside the running worker whose time budget is spent, so
449 * this must ONLY enqueue: the queue-cleanup path used for fresh starts
450 * and revivals would mark this very worker's own action as failed.
451 *
452 * @param string $run_id Run identity to continue.
453 * @return void
454 */
455 public function enqueue_worker( string $run_id ): void {
456 as_enqueue_async_action(
457 'mlsimport_background_process_per_item',
458 array( 'args' => array( 'run_id' => $run_id ) )
459 );
460 spawn_cron();
461 }
462
463 /**
464 * Queue a replacement worker for a silent run (watchdog path).
465 *
466 * The watchdog runs outside any worker, so the full start-style queue
467 * cleanup is correct here: a recorded running action belongs to a killed
468 * process, and a pending one failed to dispatch. Both are cleared before
469 * the fresh worker is queued.
470 *
471 * @param string $run_id Run identity to continue.
472 * @return void
473 */
474 public function revive_worker( string $run_id ): void {
475 $this->admin->mlsimport_enqueue_import_worker( $run_id );
476 }
477
478 /**
479 * Read the run holding the site-wide lock when it belongs to this task.
480 *
481 * @param int $task_id Import Task identifier.
482 * @return array<string, mixed> Active run record or empty array.
483 */
484 public function read_active_run( int $task_id ): array {
485 $lock = get_option( self::LOCK_OPTION, array() );
486 if ( ! is_array( $lock ) || $task_id !== (int) ( $lock['task_id'] ?? 0 ) ) {
487 return array();
488 }
489 return $this->read_run( (string) ( $lock['run_id'] ?? '' ) );
490 }
491
492 /** {@inheritDoc} */
493 public function count_listings( array $run ): array {
494 $task_id = (int) $run['task_id'];
495 $last_date = get_post_meta( $task_id, 'mlsimport_last_date', true );
496 if ( '' === $last_date ) {
497 return array( 'success' => false, 'error' => 'Complete a manual import first.' );
498 }
499 // Same transient-failure protection as fetch_listing_batch: the count
500 // call hits the same SaaS endpoint family, and an hourly run must not
501 // abort because one HTTP request hung. Retry the identical request up
502 // to twice (5s pause) before the failure is treated as real.
503 $response = null;
504 for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
505 if ( $attempt > 1 ) {
506 sleep( 5 );
507 }
508 $response = $this->admin->mlsimport_make_listing_requests( $task_id, $last_date, '', '', true );
509 if ( isset( $response['results'] ) ) {
510 break;
511 }
512 }
513 if ( ! isset( $response['results'] ) ) {
514 return array( 'success' => false, 'error' => (string) ( $response['message'] ?? 'Listings count failed.' ) );
515 }
516
517 return array( 'success' => true, 'found' => max( 0, (int) $response['results'] ) );
518 }
519
520 /** {@inheritDoc} */
521 public function advance_last_successful_sync_time( int $task_id, int $completed_at ): void {
522 update_post_meta( $task_id, 'mlsimport_last_date', wp_date( 'Y-m-d\\TH:i', $completed_at - 7200 ) );
523 }
524
525 /**
526 * Replace a stale lock only when its stored value is still unchanged.
527 *
528 * The database comparison prevents two simultaneous replacement requests
529 * from both believing they acquired the site-wide slot.
530 *
531 * @param array<string, mixed> $current Lock value that was read.
532 * @param array<string, mixed> $next New lock value.
533 * @return bool Whether this request replaced the exact stale value.
534 */
535 private function replace_stale_lock( array $current, array $next ): bool {
536 global $wpdb;
537 $changed = $wpdb->update(
538 $wpdb->options,
539 array( 'option_value' => maybe_serialize( $next ) ),
540 array(
541 'option_name' => self::LOCK_OPTION,
542 'option_value' => maybe_serialize( $current ),
543 ),
544 array( '%s' ),
545 array( '%s', '%s' )
546 );
547 wp_cache_delete( self::LOCK_OPTION, 'options' );
548 return 1 === $changed;
549 }
550
551 /**
552 * Store the compact progress shape used by the admin status endpoint.
553 *
554 * @param array<string, mixed> $run Current run record.
555 * @param array<string, mixed> $result Final result, or empty while active.
556 * @return void
557 */
558 private function write_task_status( array $run, array $result ): void {
559 $task_id = (int) $run['task_id'];
560 update_post_meta(
561 $task_id,
562 self::STATUS_META,
563 array(
564 'run_id' => (string) $run['run_id'],
565 'state' => (string) ( $run['state'] ?? 'waiting' ),
566 'handled' => (int) ( $run['handled'] ?? 0 ),
567 'expected' => (int) ( $run['expected'] ?? 0 ),
568 'error' => (string) ( $run['error'] ?? ( $result['error'] ?? '' ) ),
569 // The worker's last heartbeat. The Import Tasks list uses it
570 // (via mlsimport_task_health()) to flag a 'running' status
571 // whose worker silently died — GitHub issue #200.
572 'activity_at' => (int) ( $run['activity_at'] ?? 0 ),
573 // This method runs inside the worker process, so this is the
574 // import worker's real memory — the number administrators need
575 // to see. The polling AJAX request's own memory is irrelevant.
576 'memory' => round( memory_get_usage( true ) / 1048576, 2 ),
577 'result' => $result,
578 )
579 );
580
581 // Existing installations use this value to decide whether hourly sync is
582 // allowed. Record the first successful manual import permanently; later
583 // stopped or failed manual retries must not remove that eligibility.
584 if ( 'manual' === (string) ( $run['source'] ?? '' ) ) {
585 $state = (string) ( $run['state'] ?? 'waiting' );
586 if ( 'completed' === $state ) {
587 update_post_meta( $task_id, 'mlsimport_initial_import_completed', 1 );
588 update_post_meta( $task_id, 'mlsimport_spawn_status', 'completed' );
589 } elseif ( ! get_post_meta( $task_id, 'mlsimport_initial_import_completed', true ) ) {
590 update_post_meta( $task_id, 'mlsimport_spawn_status', 'started' );
591 }
592 }
593 }
594
595 /**
596 * Keep only ownership and heartbeat fields in the site-wide lock.
597 *
598 * @param array<string, mixed> $run Current run record.
599 * @return array<string, int|string> Compact lock value.
600 */
601 private function lock_from_run( array $run ): array {
602 return array(
603 'run_id' => (string) $run['run_id'],
604 'task_id' => (int) $run['task_id'],
605 'activity_at' => (int) ( $run['activity_at'] ?? $this->now() ),
606 );
607 }
608
609 /**
610 * Build a bounded option key without exposing the run id directly.
611 *
612 * @param string $run_id Run identity.
613 * @return string WordPress option key.
614 */
615 private function run_option_name( string $run_id ): string {
616 return 'mlsimport_import_run_' . md5( $run_id );
617 }
618 }
619