PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.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 All 37 releases
mlsimport / includes / mlsimport-reconciliation-connections.php

mlsimport-reconciliation-connections.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/mlsimport-reconciliation-connections.php

271 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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