PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.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 / mlsimport-import-health.php

mlsimport-import-health.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at includes/mlsimport-import-health.php

215 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import health watch (issue #208).
4 *
5 * File role: gives every import surface a heartbeat and turns silent failures
6 * into incidents for includes/mlsimport-alerts.php. Two things are watched:
7 *
8 * A. The hourly cron import. mlsimport.php wraps the auto-import loop in
9 * heartbeat start/progress/finish calls, which maintain one record in the
10 * mlsimport_cron_heartbeat option: phase, started_at, progress_at, items.
11 * At the top of every cron entry mlsimport_cron_heartbeat_check() looks
12 * at the PREVIOUS record: a record still in phase 'running' long after it
13 * started means that run's process died mid-loop — the exact silent
14 * failure that previously left no trace. One deduplicated incident is
15 * opened; the next clean finish resolves it.
16 *
17 * B. Manual Import Runs. mlsimport_import_health_watch_manual_run() reads
18 * the active run's public status: a run still 'waiting' (the customer
19 * sees "Preparing the import") past the threshold opens an incident, and
20 * a run that got moving again resolves it.
21 *
22 * Every threshold is filterable, so limits can be tuned without a code change.
23 *
24 * @package MLSImport
25 */
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * Record the start of an hourly cron import run.
33 *
34 * Overwrites the previous heartbeat — mlsimport_cron_heartbeat_check() must
35 * run before this at the cron entry, while the previous record is still there.
36 *
37 * @param int|null $now Unix time (tests inject; production uses time()).
38 * @return void
39 */
40 function mlsimport_cron_heartbeat_start( $now = null ) {
41 $now = null === $now ? time() : intval( $now );
42 update_option(
43 'mlsimport_cron_heartbeat',
44 array(
45 'phase' => 'running',
46 'started_at' => $now,
47 'progress_at' => $now,
48 'items' => 0,
49 ),
50 false
51 );
52 }
53
54 /**
55 * Record measurable progress inside the running cron import.
56 *
57 * @param int $items_total Items processed so far in this run.
58 * @param int|null $now Unix time (tests inject).
59 * @return void
60 */
61 function mlsimport_cron_heartbeat_progress( $items_total, $now = null ) {
62 $beat = get_option( 'mlsimport_cron_heartbeat', array() );
63 if ( ! is_array( $beat ) || 'running' !== ( $beat['phase'] ?? '' ) ) {
64 return;
65 }
66 $beat['items'] = intval( $items_total );
67 $beat['progress_at'] = null === $now ? time() : intval( $now );
68 update_option( 'mlsimport_cron_heartbeat', $beat, false );
69 }
70
71 /**
72 * Record a clean end of the cron import and resolve any died-run incident.
73 *
74 * @param int|null $now Unix time (tests inject).
75 * @return void
76 */
77 function mlsimport_cron_heartbeat_finish( $now = null ) {
78 $beat = get_option( 'mlsimport_cron_heartbeat', array() );
79 if ( ! is_array( $beat ) ) {
80 $beat = array();
81 }
82 $beat['phase'] = 'finished';
83 $beat['finished_at'] = null === $now ? time() : intval( $now );
84 update_option( 'mlsimport_cron_heartbeat', $beat, false );
85
86 // The import demonstrably works again — close the incident, if one is open.
87 mlsimport_alert_resolve( 'cron_import_died', array( 'items' => intval( $beat['items'] ?? 0 ) ) );
88 }
89
90 /**
91 * Detect a cron import whose process died mid-loop (#208).
92 *
93 * Called at the very top of every cron entry, before the new heartbeat
94 * overwrites the old one. A record still in phase 'running' longer than the
95 * stale threshold after it started can no longer be a live run — the process
96 * was killed without reaching finish. Opens one deduplicated incident with
97 * sanitized progress context; dedup means repeated hourly checks stay silent
98 * until a clean finish resolves the incident.
99 *
100 * @param int|null $now Unix time (tests inject).
101 * @return void
102 */
103 function mlsimport_cron_heartbeat_check( $now = null ) {
104 $now = null === $now ? time() : intval( $now );
105 $beat = get_option( 'mlsimport_cron_heartbeat', array() );
106 if ( ! is_array( $beat ) || 'running' !== ( $beat['phase'] ?? '' ) ) {
107 return;
108 }
109
110 /** Filter the seconds after which a still-'running' cron heartbeat counts as dead. @since 7.2 */
111 $stale_after = intval( apply_filters( 'mlsimport_import_health_cron_stale_seconds', 1800 ) );
112 if ( $now - intval( $beat['started_at'] ?? 0 ) < $stale_after ) {
113 return;
114 }
115
116 mlsimport_alert_open(
117 'cron_import_died',
118 'cron_import_died',
119 array(
120 'phase' => 'running',
121 'started_at' => intval( $beat['started_at'] ?? 0 ),
122 'progress_at' => intval( $beat['progress_at'] ?? 0 ),
123 'items' => intval( $beat['items'] ?? 0 ),
124 )
125 );
126 }
127
128 /**
129 * Watch the active Import Run for a stuck "Preparing" phase (#208).
130 *
131 * The ticket's worst churn case was an import showing "Preparing the import"
132 * with zero progress for 32 days — a run accepted into 'waiting' whose worker
133 * was never dispatched. Called from hourly cron: reads the site-wide run lock
134 * and the run it points at (the same records the execution module maintains),
135 * then applies one rule each way:
136 * - still 'waiting' past the threshold → open one incident for this run_id;
137 * - any other state (the run moved) → resolve that incident.
138 * Dedup lives in the alerts module, so repeated hourly checks stay silent.
139 *
140 * @param int|null $now Unix time (tests inject).
141 * @return void
142 */
143 function mlsimport_import_health_watch_manual_run( $now = null ) {
144 $now = null === $now ? time() : intval( $now );
145 $lock = get_option( 'mlsimport_import_run_lock', array() );
146 if ( ! is_array( $lock ) || empty( $lock['run_id'] ) ) {
147 return;
148 }
149
150 // The full run record behind the lock (same key scheme the execution
151 // environment uses: run id hashed into the option name).
152 $run = get_option( 'mlsimport_import_run_' . md5( (string) $lock['run_id'] ), array() );
153 if ( ! is_array( $run ) || empty( $run['run_id'] ) ) {
154 return;
155 }
156
157 $incident = 'import_preparing:' . $run['run_id'];
158
159 // The run moved past waiting — whatever happens next, "stuck at
160 // Preparing" is over for this run.
161 if ( 'waiting' !== (string) ( $run['state'] ?? '' ) ) {
162 mlsimport_alert_resolve( $incident, array( 'state' => (string) ( $run['state'] ?? '' ) ) );
163 return;
164 }
165
166 /** Filter the seconds a run may sit in 'waiting' before it counts as stuck. @since 7.2 */
167 $preparing_limit = intval( apply_filters( 'mlsimport_import_health_preparing_seconds', 900 ) );
168 if ( $now - intval( $run['started_at'] ?? 0 ) < $preparing_limit ) {
169 return;
170 }
171
172 mlsimport_alert_open(
173 $incident,
174 'import_preparing',
175 array(
176 'task_id' => intval( $run['task_id'] ?? 0 ),
177 'source' => (string) ( $run['source'] ?? '' ),
178 'waiting_seconds' => $now - intval( $run['started_at'] ?? 0 ),
179 )
180 );
181 }
182
183 /**
184 * Show the broken-connection remediation notice in wp-admin (#208).
185 *
186 * The counterpart of the connection-health state ThemeImport records: when the
187 * SaaS has definitively rejected (or is missing) the account credentials, the
188 * administrator sees one clear error notice telling them exactly what to fix
189 * and where — instead of imports silently doing nothing. Healthy and unknown
190 * states render nothing. Never prints any credential value.
191 *
192 * @return void
193 */
194 function mlsimport_connection_health_notice() {
195 if ( ! current_user_can( 'manage_options' ) ) {
196 return;
197 }
198 $health = get_option( 'mlsimport_connection_health', array() );
199 $status = is_array( $health ) ? (string) ( $health['status'] ?? '' ) : '';
200 if ( 'credentials_invalid' !== $status && 'credentials_missing' !== $status ) {
201 return;
202 }
203
204 $message = 'credentials_missing' === $status
205 ? esc_html__( 'MLSImport cannot connect: no account credentials are configured, so imports and hourly sync are stopped.', 'mlsimport' )
206 : esc_html__( 'MLSImport cannot connect: the SaaS rejected your account credentials, so imports and hourly sync are stopped. Re-enter your MLSImport username and password.', 'mlsimport' );
207
208 echo '<div class="notice notice-error"><p><strong>MLSImport</strong> — '
209 . $message
210 . ' <a href="' . esc_url( admin_url( 'admin.php?page=mlsimport_plugin_options' ) ) . '">'
211 . esc_html__( 'Open MLSImport settings', 'mlsimport' )
212 . '</a></p></div>';
213 }
214 add_action( 'admin_notices', 'mlsimport_connection_health_notice' );
215