| 1 |
<?php |
| 2 |
/** |
| 3 |
* Per-connection reconciliation (issue #279, decision #269, spec #273). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* With N MLS connections, one unscoped daily reconciliation would compare one |
| 8 |
* MLS's snapshot against EVERY local listing and mark the other MLS's posts |
| 9 |
* as deletion candidates. Decision #269 keeps the deep decision module |
| 10 |
* (Mlsimport_Reconciliation) completely untouched and instead runs it once |
| 11 |
* per connection, each run against a connection-scoped environment: |
| 12 |
* |
| 13 |
* - snapshot fetched with mls_id and verified by the #276 echo guard |
| 14 |
* (a missing/mismatched echo or a 'not_entitled' rejection aborts ONLY |
| 15 |
* that connection's sub-run with zero deletions — the connection-level |
| 16 |
* sibling of "empty status never deletes"), |
| 17 |
* - inventory limited in SQL to posts stamped 'mlsimport_mls_id' = X |
| 18 |
* (#278), so orphaned and unstamped listings sit outside every inventory |
| 19 |
* and are structurally undeletable, |
| 20 |
* - the 80% plausibility guard applied by the module per connection. |
| 21 |
* |
| 22 |
* One global lock spans the whole loop; outcomes are recorded per connection; |
| 23 |
* orphaned stamps (matching no registered connection) surface one deduplicated |
| 24 |
* health incident. The single retry event re-runs all connections — plan-first |
| 25 |
* means already-completed connections converge to no-op keeps. |
| 26 |
* |
| 27 |
* @since 7.2.0 |
| 28 |
* @package Mlsimport |
| 29 |
*/ |
| 30 |
|
| 31 |
if ( ! defined( 'ABSPATH' ) ) { |
| 32 |
exit; |
| 33 |
} |
| 34 |
|
| 35 |
require_once __DIR__ . '/class-mlsimport-reconciliation.php'; |
| 36 |
|
| 37 |
/** |
| 38 |
* Refuse an MLS-scoped snapshot that may not be used for this connection. |
| 39 |
* |
| 40 |
* Step by step: |
| 41 |
* 1. A stable 'not_entitled' rejection marks the connection in the registry |
| 42 |
* (import paths then skip it too) and aborts the sub-run by throwing. |
| 43 |
* 2. Any response that does not echo the requested mls_id back — a legacy |
| 44 |
* account-wide snapshot, a misrouted response — is refused by the #276 |
| 45 |
* echo guard and aborts the sub-run by throwing. |
| 46 |
* 3. Only a verified same-connection snapshot is returned for planning. |
| 47 |
* |
| 48 |
* Throwing is deliberate: the untouched decision module already converts a |
| 49 |
* fetch throw into an 'aborted' outcome with zero deletions, which is exactly |
| 50 |
* the invariant — an unreadable connection never deletes. |
| 51 |
* |
| 52 |
* @param mixed $answer Raw decoded SaaS reconciliation response. |
| 53 |
* @param int $mls_id The connection the request was scoped to. |
| 54 |
* @return array<string, mixed> The verified snapshot response. |
| 55 |
* @throws RuntimeException With a stable refusal code when the snapshot is unusable. |
| 56 |
*/ |
| 57 |
function mlsimport_reconciliation_guarded_snapshot( $answer, int $mls_id ): array { |
| 58 |
// Step 1: the SaaS says this account may not use this MLS any more. |
| 59 |
if ( mlsimport_response_not_entitled( $answer ) ) { |
| 60 |
mlsimport_mark_connection_not_entitled( $mls_id ); |
| 61 |
throw new RuntimeException( 'snapshot_not_entitled' ); |
| 62 |
} |
| 63 |
|
| 64 |
// Step 2: no echo / wrong echo => the snapshot is not authoritative for |
| 65 |
// this connection and must never drive its deletions. |
| 66 |
if ( ! mlsimport_mls_scoped_echo_ok( $answer, $mls_id ) ) { |
| 67 |
throw new RuntimeException( 'snapshot_echo_mismatch' ); |
| 68 |
} |
| 69 |
|
| 70 |
// Step 3: verified — hand it to the decision module unchanged. |
| 71 |
return $answer; |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* Run the untouched decision module once per connection, sequentially. |
| 76 |
* |
| 77 |
* The factory builds one connection-scoped environment per mls_id; each |
| 78 |
* sub-run is a full Mlsimport_Reconciliation run against that environment. |
| 79 |
* An aborted sub-run simply moves the loop to the next connection — outcome |
| 80 |
* isolation is the module's own contract (its throw handling and guards all |
| 81 |
* end in a zero-deletion abort). |
| 82 |
* |
| 83 |
* @param array<int, int> $mls_ids Connection ids in priority order. |
| 84 |
* @param callable $make_environment fn( int $mls_id ): Mlsimport_Reconciliation_Environment. |
| 85 |
* @return array<int, array<string, int|string>> Reconciliation Outcome per mls_id. |
| 86 |
*/ |
| 87 |
function mlsimport_reconciliation_loop( array $mls_ids, callable $make_environment ): array { |
| 88 |
$outcomes = array(); |
| 89 |
|
| 90 |
// Sequential sub-runs: one full module run per connection. |
| 91 |
foreach ( $mls_ids as $mls_id ) { |
| 92 |
$environment = call_user_func( $make_environment, (int) $mls_id ); |
| 93 |
$outcomes[ (int) $mls_id ] = ( new Mlsimport_Reconciliation( $environment ) )->reconcile_current_listings(); |
| 94 |
} |
| 95 |
|
| 96 |
return $outcomes; |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Find provenance stamps on listing posts that match no registered connection. |
| 101 |
* |
| 102 |
* A listing stamped with an mls_id whose connection was deleted (or stamped 0 |
| 103 |
* by an unbound task) is outside every scoped inventory — reconciliation can |
| 104 |
* structurally never delete it — but it is also never cleaned up, so it must |
| 105 |
* surface as a health incident. Unstamped pre-migration posts carry no |
| 106 |
* provenance meta at all and are intentionally NOT reported: they are safe by |
| 107 |
* construction until the migration stamps them. |
| 108 |
* |
| 109 |
* @param array<int, int> $registered_ids Currently registered connection ids. |
| 110 |
* @return array<int, int> Distinct orphaned mls_id stamps (may include 0). |
| 111 |
*/ |
| 112 |
function mlsimport_reconciliation_orphan_mls_ids( array $registered_ids ): array { |
| 113 |
global $wpdb; |
| 114 |
|
| 115 |
// The stamps to keep: every registered id, compared as the string the |
| 116 |
// postmeta table stores. |
| 117 |
$registered = array_map( 'strval', array_map( 'intval', $registered_ids ) ); |
| 118 |
|
| 119 |
// Listing posts are identified by their stable '_mlsimport_listing_key' |
| 120 |
// meta; draft/trash posts are outside reconciliation and outside this scan. |
| 121 |
$exclusion = ''; |
| 122 |
if ( array() !== $registered ) { |
| 123 |
$exclusion = ' AND provenance.meta_value NOT IN (' . implode( ', ', array_fill( 0, count( $registered ), '%s' ) ) . ')'; |
| 124 |
} |
| 125 |
|
| 126 |
// Intentional uncached direct scan at the infrastructure boundary. |
| 127 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared |
| 128 |
$stamps = $wpdb->get_col( |
| 129 |
$wpdb->prepare( |
| 130 |
"SELECT DISTINCT provenance.meta_value |
| 131 |
FROM {$wpdb->postmeta} provenance |
| 132 |
INNER JOIN {$wpdb->posts} posts ON posts.ID = provenance.post_id |
| 133 |
INNER JOIN {$wpdb->postmeta} listing_key |
| 134 |
ON listing_key.post_id = posts.ID AND listing_key.meta_key = %s |
| 135 |
WHERE provenance.meta_key = %s |
| 136 |
AND posts.post_status NOT IN ('draft', 'trash')" . $exclusion, // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 137 |
array_merge( array( '_mlsimport_listing_key', 'mlsimport_mls_id' ), $registered ) |
| 138 |
) |
| 139 |
); |
| 140 |
|
| 141 |
$orphans = array_map( 'intval', is_array( $stamps ) ? $stamps : array() ); |
| 142 |
sort( $orphans ); |
| 143 |
return $orphans; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* The daily/retry entry point: reconcile every registered connection. |
| 148 |
* |
| 149 |
* Step by step: |
| 150 |
* 1. Legacy degrade: an empty registry (install not yet using connections) |
| 151 |
* runs today's single unscoped reconciliation unchanged — no echo guard, |
| 152 |
* no scoping — exactly the pre-multi-MLS behavior. |
| 153 |
* 2. One global lock spans the whole loop (reconciliation vs import mutual |
| 154 |
* exclusion stays global); when it is held, every connection reports |
| 155 |
* 'already_running' and nothing runs. |
| 156 |
* 3. Orphan scan: stamps matching no registered connection open ONE |
| 157 |
* deduplicated health incident (they are already structurally |
| 158 |
* undeletable); a clean scan resolves it so the alert re-arms. |
| 159 |
* 4. Loop the connections in priority order: each sub-run gets a scoped |
| 160 |
* environment whose fetcher requests `reconciliation?mls_id=X` and passes |
| 161 |
* the guarded-snapshot check above before the module may plan anything. |
| 162 |
* 5. Outcomes are recorded per connection (non-autoloaded option + error_log). |
| 163 |
* |
| 164 |
* @return array<int, array<string, int|string>> Reconciliation Outcome per |
| 165 |
* mls_id (key 0 = the single legacy unscoped run). |
| 166 |
*/ |
| 167 |
function mlsimport_reconciliation_run_connections(): array { |
| 168 |
global $mlsimport; |
| 169 |
|
| 170 |
$connections = Mlsimport_Connections::all(); |
| 171 |
|
| 172 |
// Step 1: no registered connections => the unchanged legacy single run. |
| 173 |
if ( array() === $connections ) { |
| 174 |
$environment = new Mlsimport_Reconciliation_WordPress_Environment( |
| 175 |
static function () use ( $mlsimport ): array { |
| 176 |
return $mlsimport->admin->mlsimport_saas_get_mls_reconciliation_data(); |
| 177 |
} |
| 178 |
); |
| 179 |
$outcomes = array( 0 => ( new Mlsimport_Reconciliation( $environment ) )->reconcile_current_listings() ); |
| 180 |
mlsimport_reconciliation_record_outcomes( $outcomes ); |
| 181 |
return $outcomes; |
| 182 |
} |
| 183 |
|
| 184 |
// Step 2: claim the one global lock for the whole loop. The holder is a |
| 185 |
// plain environment instance — reusing the canonical add_option lock |
| 186 |
// instead of duplicating it here. The scoped environments below |
| 187 |
// deliberately do not lock (mls_id > 0 makes their lock methods no-ops), |
| 188 |
// so this holder is the only owner. |
| 189 |
$lock = new Mlsimport_Reconciliation_WordPress_Environment( |
| 190 |
static function (): array { |
| 191 |
return array(); |
| 192 |
} |
| 193 |
); |
| 194 |
if ( ! $lock->acquire_lock() ) { |
| 195 |
// Mirrors Mlsimport_Reconciliation::outcome( 'already_running', |
| 196 |
// 'reconciliation_already_running' ) — keep the shapes in sync. |
| 197 |
$busy = array( |
| 198 |
'status' => 'already_running', |
| 199 |
'reason' => 'reconciliation_already_running', |
| 200 |
'kept' => 0, |
| 201 |
'deleted' => 0, |
| 202 |
'failed' => 0, |
| 203 |
); |
| 204 |
return array_fill_keys( array_keys( $connections ), $busy ); |
| 205 |
} |
| 206 |
|
| 207 |
try { |
| 208 |
// Step 3: orphaned provenance stamps become one deduplicated incident; |
| 209 |
// a clean scan resolves it (silent when not open) so the alert re-arms |
| 210 |
// once the stamps are repaired. |
| 211 |
$orphans = mlsimport_reconciliation_orphan_mls_ids( array_keys( $connections ) ); |
| 212 |
if ( array() !== $orphans ) { |
| 213 |
mlsimport_alert_open( |
| 214 |
'reconciliation_orphaned_listings', |
| 215 |
'reconciliation_orphaned_listings', |
| 216 |
array( 'mls_ids' => $orphans ) |
| 217 |
); |
| 218 |
} else { |
| 219 |
mlsimport_alert_resolve( 'reconciliation_orphaned_listings' ); |
| 220 |
} |
| 221 |
|
| 222 |
// Step 4: sequential scoped sub-runs. A guard refusal aborts the |
| 223 |
// sub-run through the module's fetch-throw handling; the specific |
| 224 |
// refusal code is logged here, where it is in hand. |
| 225 |
$outcomes = mlsimport_reconciliation_loop( |
| 226 |
array_keys( $connections ), |
| 227 |
static function ( int $mls_id ) use ( $mlsimport ) { |
| 228 |
return new Mlsimport_Reconciliation_WordPress_Environment( |
| 229 |
static function () use ( $mlsimport, $mls_id ): array { |
| 230 |
$answer = $mlsimport->admin->mlsimport_saas_get_mls_reconciliation_data( $mls_id ); |
| 231 |
try { |
| 232 |
return mlsimport_reconciliation_guarded_snapshot( $answer, $mls_id ); |
| 233 |
} catch ( RuntimeException $refusal ) { |
| 234 |
error_log( 'MLSImport reconciliation snapshot refused for connection ' . $mls_id . ': ' . $refusal->getMessage() ); |
| 235 |
throw $refusal; |
| 236 |
} |
| 237 |
}, |
| 238 |
$mls_id |
| 239 |
); |
| 240 |
} |
| 241 |
); |
| 242 |
|
| 243 |
// Step 5: per-connection record for support/telemetry visibility. |
| 244 |
mlsimport_reconciliation_record_outcomes( $outcomes ); |
| 245 |
return $outcomes; |
| 246 |
} finally { |
| 247 |
$lock->release_lock(); |
| 248 |
} |
| 249 |
} |
| 250 |
|
| 251 |
/** |
| 252 |
* Record the run's per-connection outcomes (decision #269: outcomes are |
| 253 |
* recorded per connection) and log one line per connection. |
| 254 |
* |
| 255 |
* @param array<int, array<string, int|string>> $outcomes Outcome per mls_id. |
| 256 |
* @return void |
| 257 |
*/ |
| 258 |
function mlsimport_reconciliation_record_outcomes( array $outcomes ): void { |
| 259 |
// Non-autoloaded (update_option creates it that way on first write): |
| 260 |
// only support/diagnostic paths ever read this back. |
| 261 |
$record = array( |
| 262 |
'time' => time(), |
| 263 |
'outcomes' => $outcomes, |
| 264 |
); |
| 265 |
update_option( 'mlsimport_reconciliation_last_run', $record, false ); |
| 266 |
|
| 267 |
foreach ( $outcomes as $mls_id => $outcome ) { |
| 268 |
error_log( 'MLSImport reconciliation outcome for connection ' . (int) $mls_id . ': ' . wp_json_encode( $outcome ) ); |
| 269 |
} |
| 270 |
} |
| 271 |
|