| 1 |
<?php |
| 2 |
/** |
| 3 |
* Import task connection binding + cron isolation (issue #277, spec #273/#265). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* With multi-MLS support every Import Task (mlsimport_item) belongs to exactly |
| 8 |
* ONE connection for its whole life, stamped at creation in the post meta |
| 9 |
* 'mlsimport_item_mls_id' (decision #265 — a task's filters are enum values |
| 10 |
* from one specific MLS, so re-binding would produce garbage by construction; |
| 11 |
* targeting another MLS means creating a new task). |
| 12 |
* |
| 13 |
* This module owns everything about that binding: |
| 14 |
* - resolving a task's mls_id (with the legacy current-connection fallback |
| 15 |
* for unstamped tasks, so pre-multi-MLS installs behave exactly as before), |
| 16 |
* - stamping the binding once at creation (picker choice, single-connection |
| 17 |
* auto-set, or current-connection fallback), |
| 18 |
* - the per-task connection gate the hourly cron uses for failure isolation: |
| 19 |
* tasks on a broken connection skip WITH a recorded reason while tasks on |
| 20 |
* healthy connections keep importing in the same run, |
| 21 |
* - the per-connection sync-failure record (telemetry scoped by mls_id), |
| 22 |
* - mirroring the connection-test result into the registry record's status, |
| 23 |
* which is what the gate reads for non-current connections. |
| 24 |
* |
| 25 |
* The task-scoped Field Configuration read lives where the projection cache |
| 26 |
* already lives: mlsimport_active_field_configuration( $refresh, $mls_id ). |
| 27 |
* |
| 28 |
* @since 7.2.0 |
| 29 |
* @package Mlsimport |
| 30 |
*/ |
| 31 |
|
| 32 |
if ( ! defined( 'ABSPATH' ) ) { |
| 33 |
exit; |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Resolve the mls_id an Import Task is bound to. |
| 38 |
* |
| 39 |
* Step by step: |
| 40 |
* 1. A stamped task returns its own binding — never the global selection. |
| 41 |
* 2. An unstamped task (created before multi-MLS, or programmatically) falls |
| 42 |
* back to the current connection, which is exactly what such a task has |
| 43 |
* always imported from. The #274 migration stamps existing tasks, so this |
| 44 |
* fallback only carries genuinely legacy cases. |
| 45 |
* |
| 46 |
* @param int $task_id Import Task post id. |
| 47 |
* @return int The bound mls_id, or 0 when the install is unconfigured. |
| 48 |
*/ |
| 49 |
function mlsimport_task_mls_id( int $task_id ): int { |
| 50 |
// Step 1: the task's own stamp wins. |
| 51 |
$stamped = (int) get_post_meta( $task_id, 'mlsimport_item_mls_id', true ); |
| 52 |
if ( $stamped > 0 ) { |
| 53 |
return $stamped; |
| 54 |
} |
| 55 |
|
| 56 |
// Step 2: legacy fallback — the current connection. |
| 57 |
return mlsimport_current_mls_id(); |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Bind a task to one connection, exactly once (immutable afterwards). |
| 62 |
* |
| 63 |
* Step by step: |
| 64 |
* 1. A task that is already bound keeps its binding — the posted value is |
| 65 |
* ignored, so no form manipulation can re-bind a task. |
| 66 |
* 2. A requested id is honored only when it names a REGISTERED connection |
| 67 |
* (the metabox picker only offers registered ones). |
| 68 |
* 3. Without a valid request: exactly one registered connection auto-binds |
| 69 |
* (no picker was shown); otherwise the current connection is stamped — |
| 70 |
* the pre-#271 case where the registry does not mirror the configured MLS. |
| 71 |
* 4. Stamp only a positive id; an unconfigured install stays unstamped and |
| 72 |
* resolves through the fallback in mlsimport_task_mls_id() until an MLS |
| 73 |
* is configured. |
| 74 |
* |
| 75 |
* @param int $task_id Import Task post id. |
| 76 |
* @param int $requested_mls_id Connection chosen in the metabox picker (0 = none). |
| 77 |
* @return int The task's binding after the call (0 only when unconfigured). |
| 78 |
*/ |
| 79 |
function mlsimport_bind_task_connection( int $task_id, int $requested_mls_id = 0 ): int { |
| 80 |
// Step 1: bound is bound — for life. |
| 81 |
$existing = (int) get_post_meta( $task_id, 'mlsimport_item_mls_id', true ); |
| 82 |
if ( $existing > 0 ) { |
| 83 |
return $existing; |
| 84 |
} |
| 85 |
|
| 86 |
// Step 2: a picker choice must be a registered connection. |
| 87 |
$connections = Mlsimport_Connections::all(); |
| 88 |
if ( $requested_mls_id > 0 && isset( $connections[ $requested_mls_id ] ) ) { |
| 89 |
$mls_id = $requested_mls_id; |
| 90 |
} elseif ( 1 === count( $connections ) ) { |
| 91 |
// Step 3a: exactly one connection — auto-set, no picker existed. |
| 92 |
$mls_id = (int) array_key_first( $connections ); |
| 93 |
} else { |
| 94 |
// Step 3b: no usable request — the current connection. |
| 95 |
$mls_id = mlsimport_current_mls_id(); |
| 96 |
} |
| 97 |
|
| 98 |
// Step 4: persist only a real binding. |
| 99 |
if ( $mls_id > 0 ) { |
| 100 |
update_post_meta( $task_id, 'mlsimport_item_mls_id', $mls_id ); |
| 101 |
} |
| 102 |
|
| 103 |
return $mls_id; |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Decide, for ONE task's connection, whether the hourly cron may import it. |
| 108 |
* |
| 109 |
* Pure function (no WordPress reads) so the isolation rule is unit-testable. |
| 110 |
* |
| 111 |
* The rule, one case per connection kind: |
| 112 |
* - The CURRENT connection keeps today's exact behavior: the global |
| 113 |
* 'mlsimport_connection_test' flag decides (the admin screens maintain it, |
| 114 |
* and the registry record may not even exist before #271 populates it). |
| 115 |
* - Any OTHER connection is gated by its own registry record: |
| 116 |
* 'yes' (tested OK) imports; '' (untested) or 'not_entitled' (rejected by |
| 117 |
* the SaaS, #276) skips with the matching failure code. |
| 118 |
* - A connection that no longer exists in the registry is a health-incident |
| 119 |
* skip ('skip_missing') — a task is NEVER silently re-defaulted to another |
| 120 |
* connection (decision #265). |
| 121 |
* |
| 122 |
* @param array|null $record The connection's registry record, or null. |
| 123 |
* @param bool $is_current_mls Whether the task's MLS is the currently selected one. |
| 124 |
* @param string $global_connection_test The global mlsimport_connection_test flag. |
| 125 |
* @return array{action: string, code: string} action ∈ import|skip_disconnected|skip_missing. |
| 126 |
*/ |
| 127 |
function mlsimport_task_connection_gate( ?array $record, bool $is_current_mls, string $global_connection_test ): array { |
| 128 |
// The current connection: unchanged single-MLS rule. |
| 129 |
if ( $is_current_mls ) { |
| 130 |
return 'yes' === $global_connection_test |
| 131 |
? array( 'action' => 'import', 'code' => '' ) |
| 132 |
: array( 'action' => 'skip_disconnected', 'code' => 'mls_not_connected' ); |
| 133 |
} |
| 134 |
|
| 135 |
// A deleted/unregistered connection: health incident, never a re-default. |
| 136 |
if ( null === $record ) { |
| 137 |
return array( 'action' => 'skip_missing', 'code' => 'connection_missing' ); |
| 138 |
} |
| 139 |
|
| 140 |
// Any other connection: its own record status decides. |
| 141 |
if ( 'yes' === (string) ( $record['status'] ?? '' ) ) { |
| 142 |
return array( 'action' => 'import', 'code' => '' ); |
| 143 |
} |
| 144 |
|
| 145 |
return array( |
| 146 |
'action' => 'skip_disconnected', |
| 147 |
'code' => 'not_entitled' === (string) ( $record['status'] ?? '' ) ? 'not_entitled' : 'mls_not_connected', |
| 148 |
); |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Gate one cron task: resolve its connection and apply the pure rule above. |
| 153 |
* |
| 154 |
* @param int $task_id Import Task post id. |
| 155 |
* @return array{action: string, code: string, mls_id: int} Gate result plus the resolved mls_id. |
| 156 |
*/ |
| 157 |
function mlsimport_cron_task_gate( int $task_id ): array { |
| 158 |
$mls_id = mlsimport_task_mls_id( $task_id ); |
| 159 |
|
| 160 |
$gate = mlsimport_task_connection_gate( |
| 161 |
Mlsimport_Connections::get( $mls_id ), |
| 162 |
$mls_id === mlsimport_current_mls_id(), |
| 163 |
(string) get_option( 'mlsimport_connection_test', '' ) |
| 164 |
); |
| 165 |
|
| 166 |
$gate['mls_id'] = $mls_id; |
| 167 |
return $gate; |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Record one connection's sync failure — scoped by mls_id (decision #265). |
| 172 |
* |
| 173 |
* Step by step: |
| 174 |
* 1. Stamp the legacy GLOBAL failure fields exactly as the old pre-loop |
| 175 |
* connection bail-out did, so single-MLS heartbeat reporting is unchanged. |
| 176 |
* 2. Add/overwrite this connection's entry in the 'connection_sync_failures' |
| 177 |
* telemetry map (mls_id => {at, code}), so a multi-connection site can see |
| 178 |
* WHICH MLS is failing while the others keep syncing. |
| 179 |
* |
| 180 |
* @param int $mls_id The failing connection. |
| 181 |
* @param string $code Failure class ('mls_not_connected', 'not_entitled', ...). |
| 182 |
* @return void |
| 183 |
*/ |
| 184 |
function mlsimport_record_connection_sync_failure( int $mls_id, string $code ): void { |
| 185 |
// Step 1: unchanged global stamps. |
| 186 |
mlsimport_telemetry_set( 'last_sync_failed', time() ); |
| 187 |
mlsimport_telemetry_set( 'last_sync_failed_code', $code ); |
| 188 |
|
| 189 |
// Step 2: the per-connection record — written through the single map |
| 190 |
// writer (#283) so this gate and the request choke point stamp the same |
| 191 |
// 'connection_sync_failures' map the same one way. |
| 192 |
mlsimport_telemetry_record_connection_sync( $mls_id, false, $code ); |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Mirror a connection-test outcome into the registry record's status field. |
| 197 |
* |
| 198 |
* The gate above reads record status for every non-current connection, so the |
| 199 |
* test that today only writes the global flag must also keep the tested |
| 200 |
* connection's record truthful. |
| 201 |
* |
| 202 |
* Step by step: |
| 203 |
* 1. No record => no-op. Records are created by migration/#271, never here. |
| 204 |
* 2. Success => 'yes'. The SaaS validated the credentials against the live |
| 205 |
* MLS — that also supersedes an earlier not_entitled rejection. |
| 206 |
* 3. Failure => '' (untested), EXCEPT a standing 'not_entitled' mark: that |
| 207 |
* rejection is lifted only by re-entitlement (#276 rule), and softening it |
| 208 |
* to plain "untested" would lose why the connection is skipped. |
| 209 |
* 4. Every test — pass or fail — stamps tested_at, so the Connections screen |
| 210 |
* (#280) can show WHEN the status was last checked and tell "never tested" |
| 211 |
* ('' with no stamp) apart from "the last test failed" ('' with a stamp). |
| 212 |
* |
| 213 |
* @param int $mls_id The tested connection. |
| 214 |
* @param bool $tested_ok Whether the SaaS confirmed the MLS connection works. |
| 215 |
* @return void |
| 216 |
*/ |
| 217 |
function mlsimport_connection_record_test_result( int $mls_id, bool $tested_ok ): void { |
| 218 |
// Step 1: only registered connections carry status. |
| 219 |
$record = Mlsimport_Connections::get( $mls_id ); |
| 220 |
if ( null === $record ) { |
| 221 |
return; |
| 222 |
} |
| 223 |
|
| 224 |
// Step 2+3: decide the new status; keep a not_entitled mark on failure. |
| 225 |
if ( $tested_ok ) { |
| 226 |
$record['status'] = 'yes'; |
| 227 |
} elseif ( 'not_entitled' !== $record['status'] ) { |
| 228 |
$record['status'] = ''; |
| 229 |
} |
| 230 |
|
| 231 |
// Step 4: a test just ran — stamp the time regardless of the outcome. |
| 232 |
$record['tested_at'] = time(); |
| 233 |
Mlsimport_Connections::save( $record ); |
| 234 |
} |
| 235 |
|
| 236 |
|