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

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

599 lines 22.6 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 // Proven-previous-version parity: breathe for 100ms between batches so
203 // the database and the SaaS API get a gap between bursts of work. The
204 // first batch of a run starts immediately.
205 if ( $skip > 0 ) {
206 usleep( 100000 );
207 }
208
209 // One transient SaaS hang (observed: a single 120s cURL timeout at batch
210 // 425/1039 while the surrounding 41 fetches took ~2.5s) must not abort a
211 // long Import Run. Retry the identical request up to twice before the
212 // failure is real; on final failure surface the transport error text
213 // (the API client returns it as a string) instead of a generic message.
214 $response = null;
215 for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
216 if ( $attempt > 1 ) {
217 // Each retry is a diagnosable event: a run that succeeds only
218 // on attempt 2 still tells the log the SaaS call hung once.
219 mlsimport_saas_single_write_import_custom_logs(
220 'Listings fetch retry ' . $attempt . '/3 at offset ' . $skip . '.' . PHP_EOL,
221 'manual'
222 );
223 sleep( 5 );
224 }
225 $fetch_started_at = microtime( true );
226 $response = $this->admin->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
227 $fetch_seconds = microtime( true ) - $fetch_started_at;
228 // A successful but slow fetch is the early warning for the
229 // transient 120s hangs observed in production-size runs.
230 if ( $fetch_seconds > 10 ) {
231 mlsimport_saas_single_write_import_custom_logs(
232 'Slow listings fetch: ' . round( $fetch_seconds, 1 ) . 's at offset ' . $skip . ' (attempt ' . $attempt . ').' . PHP_EOL,
233 'manual'
234 );
235 }
236 if ( is_array( $response ) && isset( $response['data'] ) && is_array( $response['data'] ) ) {
237 break;
238 }
239 }
240 if ( ! is_array( $response ) || ! isset( $response['data'] ) || ! is_array( $response['data'] ) ) {
241 $error = 'Listings request failed.';
242 if ( is_string( $response ) && '' !== $response ) {
243 $error = $response;
244 } elseif ( is_array( $response ) && '' !== (string) ( $response['message'] ?? '' ) ) {
245 $error = (string) $response['message'];
246 }
247 mlsimport_saas_single_write_import_custom_logs(
248 'Listings fetch FAILED after 3 attempts at offset ' . $skip . ': ' . $error . PHP_EOL,
249 'manual'
250 );
251 return array( 'success' => false, 'error' => $error );
252 }
253
254 return array( 'success' => true, 'data' => $response['data'] );
255 }
256
257 /**
258 * Translate live Import Task settings and save one listing through the module.
259 *
260 * The configuration hash includes every update-time choice that can require a
261 * rewrite when MLS data is unchanged. The public listing outcome is translated
262 * into the Import Run's success/error shape without hiding photo warnings.
263 *
264 * @param array<string, mixed> $run Current Import Run.
265 * @param array<string, mixed> $listing Incoming raw listing.
266 * @return array<string, mixed> Import Run save result.
267 */
268 public function save_listing( array $run, array $listing ): array {
269 $task_id = (int) $run['task_id'];
270 $user_id = (int) get_post_meta( $task_id, 'mlsimport_item_property_user', true );
271 if ( 0 === $user_id ) {
272 $user_id = (int) get_post_field( 'post_author', $task_id );
273 }
274 $title_format = (string) get_post_meta( $task_id, 'mlsimport_item_title_format', true );
275 if ( '' === $title_format ) {
276 $sync_options = get_option( 'mlsimport_admin_mls_sync', array() );
277 $title_format = is_array( $sync_options ) ? (string) ( $sync_options['title_format'] ?? '' ) : '';
278 }
279 $field_configuration = mlsimport_active_field_configuration();
280 $use_mls_agent = ! empty( get_post_meta( $task_id, 'mlsimport_item_use_mls_agent', true ) );
281 $config_version = hash(
282 'sha256',
283 wp_json_encode(
284 array(
285 'user_id' => $user_id,
286 'title_format' => $title_format,
287 'field_configuration' => $field_configuration,
288 'use_mls_agent' => $use_mls_agent,
289 )
290 )
291 );
292 $options = array(
293 'mlsimport_item_standardstatus' => get_post_meta( $task_id, 'mlsimport_item_standardstatus', true ),
294 'mlsimport_item_standardstatusprotect' => get_post_meta( $task_id, 'mlsimport_item_standardstatusprotect', true ),
295 'mlsimport_item_property_user' => $user_id,
296 'mlsimport_item_agent' => get_post_meta( $task_id, 'mlsimport_item_agent', true ),
297 'mlsimport_item_use_mls_agent' => $use_mls_agent,
298 'mlsimport_item_property_status' => get_post_meta( $task_id, 'mlsimport_item_property_status', true ),
299 'mlsimport_field_configuration' => $field_configuration,
300 'mlsimport_item_title_format' => $title_format,
301 'mlsimport_write_config_version' => $config_version,
302 );
303 // Proven-previous-version parity: suspend the two heavy post-write hook
304 // stacks while this one listing is written, so third-party save handlers
305 // (SEO indexers, cache purgers, notifiers) do not run once per imported
306 // listing and per attachment. Restored in finally so a throwing writer
307 // can never leave the site with its save hooks disabled.
308 global $wp_filter;
309 $suspended_filters = array();
310 foreach ( array( 'save_post', 'transition_post_status' ) as $suspended_hook ) {
311 if ( isset( $wp_filter[ $suspended_hook ] ) ) {
312 $suspended_filters[ $suspended_hook ] = $wp_filter[ $suspended_hook ];
313 $wp_filter[ $suspended_hook ] = new WP_Hook();
314 }
315 }
316 try {
317 $saved = $this->admin->theme_importer->mlsimportSaasPrepareToImportPerItem(
318 $listing,
319 array( 'item_id' => $task_id ),
320 'automatic' === (string) ( $run['source'] ?? '' ) ? 'cron' : 'manual',
321 $options
322 );
323 } finally {
324 foreach ( $suspended_filters as $suspended_hook => $hook_object ) {
325 $wp_filter[ $suspended_hook ] = $hook_object;
326 }
327 }
328
329 // The one-listing writer is forbidden to flush site-wide caches, so the
330 // batch context must release memory after every listing. Without this,
331 // each imported property and its attachments stay in the runtime object
332 // cache until the worker dies at the PHP memory limit mid-run (observed
333 // as a fatal at 75 of 100 listings under a 256M limit). Mirror Action
334 // Scheduler's own between-actions cleanup: flush only the runtime cache
335 // when supported so an external object cache is not invalidated.
336 if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ) ) {
337 wp_cache_flush_runtime();
338 } elseif ( ! wp_using_ext_object_cache() ) {
339 wp_cache_flush();
340 }
341 // Sites running with SAVEQUERIES accumulate every query in memory; the
342 // proven cpt-mlsimport import loop cleared this each listing as well.
343 global $wpdb;
344 $wpdb->queries = array();
345 gc_collect_cycles();
346 if ( false === $saved || is_wp_error( $saved ) || 'failed' === ( $saved['outcome'] ?? '' ) ) {
347 $error = is_wp_error( $saved ) ? $saved->get_error_message() : 'Theme writer reported failure.';
348 if ( is_array( $saved ) && '' !== (string) ( $saved['error'] ?? '' ) ) {
349 $error = (string) $saved['error'];
350 }
351 // Name the exact listing: the run result only keeps the LAST error,
352 // so without this line a single bad listing among a thousand is
353 // impossible to find after the run.
354 mlsimport_saas_single_write_import_custom_logs(
355 'Listing save FAILED for ' . (string) ( $listing['ListingKey'] ?? 'unknown-key' ) . ': ' . $error . PHP_EOL,
356 'manual'
357 );
358 return array( 'success' => false, 'error' => $error );
359 }
360
361 // Photo/meta warnings do not fail the listing, so they never reach the
362 // run result — the import log is their only permanent record.
363 $warnings = is_array( $saved ) ? (array) ( $saved['warnings'] ?? array() ) : array();
364 if ( ! empty( $warnings ) ) {
365 mlsimport_saas_single_write_import_custom_logs(
366 'Listing ' . (string) ( $listing['ListingKey'] ?? 'unknown-key' ) . ' saved with warnings: '
367 . implode( ' | ', array_map( 'strval', $warnings ) ) . PHP_EOL,
368 'manual'
369 );
370 }
371 return array(
372 'success' => true,
373 'outcome' => is_array( $saved ) ? (string) ( $saved['outcome'] ?? 'updated' ) : 'updated',
374 'warnings' => $warnings,
375 );
376 }
377
378 /** {@inheritDoc} */
379 public function request_stop( int $task_id ): bool {
380 $lock = get_option( self::LOCK_OPTION, array() );
381 if ( ! is_array( $lock ) || $task_id !== (int) ( $lock['task_id'] ?? 0 ) ) {
382 return false;
383 }
384 $run_id = (string) $lock['run_id'];
385 $run = $this->read_run( $run_id );
386 $run['stop_requested'] = true;
387 update_option( $this->run_option_name( $run_id ), $run, false );
388
389 // Stop is final for the administrator: record the stopped result and
390 // release the site-wide slot right away so a new import can start
391 // immediately. A still-live worker sees stop_requested at its next
392 // listing boundary and exits without touching this status (update_run
393 // and finish_run both skip status/lock writes once the slot is gone).
394 // A dead worker can no longer hold the site locked for 30 minutes.
395 $run['state'] = 'stopped';
396 $this->write_task_status(
397 $run,
398 array(
399 'state' => 'stopped',
400 'found' => (int) ( $run['expected'] ?? 0 ),
401 // The environment only tracks handled listings; the exact
402 // saved/failed split stays with the worker and is not shown
403 // for stopped runs.
404 'saved' => (int) ( $run['handled'] ?? 0 ),
405 'failed' => 0,
406 'error' => '',
407 )
408 );
409 delete_option( self::LOCK_OPTION );
410 return true;
411 }
412
413 /** {@inheritDoc} */
414 public function stop_requested( string $run_id ): bool {
415 $run = $this->read_run( $run_id );
416 return true === ( $run['stop_requested'] ?? false );
417 }
418
419 /** {@inheritDoc} */
420 public function read_task_status( int $task_id ): array {
421 $status = get_post_meta( $task_id, self::STATUS_META, true );
422 return is_array( $status ) ? $status : array();
423 }
424
425 /**
426 * Queue the follow-up worker for a chunk hand-off (issue #199).
427 *
428 * Called from inside the running worker whose time budget is spent, so
429 * this must ONLY enqueue: the queue-cleanup path used for fresh starts
430 * and revivals would mark this very worker's own action as failed.
431 *
432 * @param string $run_id Run identity to continue.
433 * @return void
434 */
435 public function enqueue_worker( string $run_id ): void {
436 as_enqueue_async_action(
437 'mlsimport_background_process_per_item',
438 array( 'args' => array( 'run_id' => $run_id ) )
439 );
440 spawn_cron();
441 }
442
443 /**
444 * Queue a replacement worker for a silent run (watchdog path).
445 *
446 * The watchdog runs outside any worker, so the full start-style queue
447 * cleanup is correct here: a recorded running action belongs to a killed
448 * process, and a pending one failed to dispatch. Both are cleared before
449 * the fresh worker is queued.
450 *
451 * @param string $run_id Run identity to continue.
452 * @return void
453 */
454 public function revive_worker( string $run_id ): void {
455 $this->admin->mlsimport_enqueue_import_worker( $run_id );
456 }
457
458 /**
459 * Read the run holding the site-wide lock when it belongs to this task.
460 *
461 * @param int $task_id Import Task identifier.
462 * @return array<string, mixed> Active run record or empty array.
463 */
464 public function read_active_run( int $task_id ): array {
465 $lock = get_option( self::LOCK_OPTION, array() );
466 if ( ! is_array( $lock ) || $task_id !== (int) ( $lock['task_id'] ?? 0 ) ) {
467 return array();
468 }
469 return $this->read_run( (string) ( $lock['run_id'] ?? '' ) );
470 }
471
472 /** {@inheritDoc} */
473 public function count_listings( array $run ): array {
474 $task_id = (int) $run['task_id'];
475 $last_date = get_post_meta( $task_id, 'mlsimport_last_date', true );
476 if ( '' === $last_date ) {
477 return array( 'success' => false, 'error' => 'Complete a manual import first.' );
478 }
479 // Same transient-failure protection as fetch_listing_batch: the count
480 // call hits the same SaaS endpoint family, and an hourly run must not
481 // abort because one HTTP request hung. Retry the identical request up
482 // to twice (5s pause) before the failure is treated as real.
483 $response = null;
484 for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
485 if ( $attempt > 1 ) {
486 sleep( 5 );
487 }
488 $response = $this->admin->mlsimport_make_listing_requests( $task_id, $last_date, '', '', true );
489 if ( isset( $response['results'] ) ) {
490 break;
491 }
492 }
493 if ( ! isset( $response['results'] ) ) {
494 return array( 'success' => false, 'error' => (string) ( $response['message'] ?? 'Listings count failed.' ) );
495 }
496
497 return array( 'success' => true, 'found' => max( 0, (int) $response['results'] ) );
498 }
499
500 /** {@inheritDoc} */
501 public function advance_last_successful_sync_time( int $task_id, int $completed_at ): void {
502 update_post_meta( $task_id, 'mlsimport_last_date', wp_date( 'Y-m-d\\TH:i', $completed_at - 7200 ) );
503 }
504
505 /**
506 * Replace a stale lock only when its stored value is still unchanged.
507 *
508 * The database comparison prevents two simultaneous replacement requests
509 * from both believing they acquired the site-wide slot.
510 *
511 * @param array<string, mixed> $current Lock value that was read.
512 * @param array<string, mixed> $next New lock value.
513 * @return bool Whether this request replaced the exact stale value.
514 */
515 private function replace_stale_lock( array $current, array $next ): bool {
516 global $wpdb;
517 $changed = $wpdb->update(
518 $wpdb->options,
519 array( 'option_value' => maybe_serialize( $next ) ),
520 array(
521 'option_name' => self::LOCK_OPTION,
522 'option_value' => maybe_serialize( $current ),
523 ),
524 array( '%s' ),
525 array( '%s', '%s' )
526 );
527 wp_cache_delete( self::LOCK_OPTION, 'options' );
528 return 1 === $changed;
529 }
530
531 /**
532 * Store the compact progress shape used by the admin status endpoint.
533 *
534 * @param array<string, mixed> $run Current run record.
535 * @param array<string, mixed> $result Final result, or empty while active.
536 * @return void
537 */
538 private function write_task_status( array $run, array $result ): void {
539 $task_id = (int) $run['task_id'];
540 update_post_meta(
541 $task_id,
542 self::STATUS_META,
543 array(
544 'run_id' => (string) $run['run_id'],
545 'state' => (string) ( $run['state'] ?? 'waiting' ),
546 'handled' => (int) ( $run['handled'] ?? 0 ),
547 'expected' => (int) ( $run['expected'] ?? 0 ),
548 'error' => (string) ( $run['error'] ?? ( $result['error'] ?? '' ) ),
549 // The worker's last heartbeat. The Import Tasks list uses it
550 // (via mlsimport_task_health()) to flag a 'running' status
551 // whose worker silently died — GitHub issue #200.
552 'activity_at' => (int) ( $run['activity_at'] ?? 0 ),
553 // This method runs inside the worker process, so this is the
554 // import worker's real memory — the number administrators need
555 // to see. The polling AJAX request's own memory is irrelevant.
556 'memory' => round( memory_get_usage( true ) / 1048576, 2 ),
557 'result' => $result,
558 )
559 );
560
561 // Existing installations use this value to decide whether hourly sync is
562 // allowed. Record the first successful manual import permanently; later
563 // stopped or failed manual retries must not remove that eligibility.
564 if ( 'manual' === (string) ( $run['source'] ?? '' ) ) {
565 $state = (string) ( $run['state'] ?? 'waiting' );
566 if ( 'completed' === $state ) {
567 update_post_meta( $task_id, 'mlsimport_initial_import_completed', 1 );
568 update_post_meta( $task_id, 'mlsimport_spawn_status', 'completed' );
569 } elseif ( ! get_post_meta( $task_id, 'mlsimport_initial_import_completed', true ) ) {
570 update_post_meta( $task_id, 'mlsimport_spawn_status', 'started' );
571 }
572 }
573 }
574
575 /**
576 * Keep only ownership and heartbeat fields in the site-wide lock.
577 *
578 * @param array<string, mixed> $run Current run record.
579 * @return array<string, int|string> Compact lock value.
580 */
581 private function lock_from_run( array $run ): array {
582 return array(
583 'run_id' => (string) $run['run_id'],
584 'task_id' => (int) $run['task_id'],
585 'activity_at' => (int) ( $run['activity_at'] ?? $this->now() ),
586 );
587 }
588
589 /**
590 * Build a bounded option key without exposing the run id directly.
591 *
592 * @param string $run_id Run identity.
593 * @return string WordPress option key.
594 */
595 private function run_option_name( string $run_id ): string {
596 return 'mlsimport_import_run_' . md5( $run_id );
597 }
598 }
599