PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / presence-store.php

presence-store.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin trunk, at includes/presence-store.php

311 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site-scoped presence storage and recoverable legacy-option import.
4 *
5 * @package OpenStation
6 */
7 defined( 'ABSPATH' ) || exit;
8
9 require_once __DIR__ . '/storage-primary.php';
10
11 /** Storage migration checkpoint; kept separate from unrelated migrations. */
12 const OPENSTATION_PRESENCE_STORAGE_OPTION = 'openstation_presence_storage';
13
14 // This group must never survive a request, including with persistent caches.
15 wp_cache_add_non_persistent_groups( 'openstation_presence_request' );
16
17 /**
18 * Current connection/site request cache key.
19 *
20 * @internal
21 * @return string
22 */
23 function openstation_presence_cache_key() {
24 global $wpdb;
25 return spl_object_id( $wpdb ) . ':' . openstation_presence_table();
26 }
27
28 /**
29 * Invalidate reads after writes, including legacy fallback and pruning.
30 *
31 * @internal
32 * @return void
33 */
34 function openstation_presence_invalidate_records() {
35 wp_cache_delete( openstation_presence_cache_key(), 'openstation_presence_request' );
36 }
37
38 /**
39 * Current site's table; never use the network's base prefix for presence.
40 *
41 * @internal
42 * @return string
43 */
44 function openstation_presence_table() {
45 global $wpdb;
46 return $wpdb->prefix . 'openstation_presence';
47 }
48
49 /**
50 * Read the legacy option directly, bypassing caches held by another worker.
51 *
52 * @internal
53 * @return array|WP_Error Normalized records, or a failed read.
54 */
55 function openstation_presence_legacy_records() {
56 global $wpdb;
57 $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s", OPENSTATION_PRESENCE_OPTION ) );
58 if ( '' !== $wpdb->last_error ) {
59 return new WP_Error( 'openstation_presence_read_failed', __( 'Could not read presence.', 'desktop-mode' ) );
60 }
61 $raw = maybe_unserialize( $raw );
62 $out = array();
63 foreach ( is_array( $raw ) ? $raw : array() as $uid => $record ) {
64 if ( (int) $uid <= 0 || ! is_array( $record ) ) {
65 continue;
66 }
67 $out[ (int) $uid ] = array(
68 'last_seen_ms' => max( 0, (int) ( $record['last_seen_ms'] ?? 0 ) ),
69 'last_active_ms' => max( 0, (int) ( $record['last_active_ms'] ?? 0 ) ),
70 );
71 }
72 return $out;
73 }
74
75 /**
76 * Merge timestamps atomically. Away is an explicit user intent, not activity.
77 *
78 * The private inactive_at_ms fence keeps a delayed active request from undoing
79 * a later "set away" request. A genuinely newer active request clears away.
80 * Ordinary inactive heartbeats pass zero activity, never a stale readback.
81 *
82 * @internal
83 * @param int $user_id User id.
84 * @param array $record Timestamps to merge.
85 * @param bool $away Whether to set away at last_seen_ms.
86 * @return bool
87 */
88 function openstation_presence_upsert( $user_id, $record, $away = false ) {
89 global $wpdb;
90 openstation_storage_use_primary();
91 $table = openstation_presence_table();
92 $result = false !== $wpdb->query(
93 $wpdb->prepare(
94 "INSERT INTO $table (user_id, last_seen_ms, last_active_ms, inactive_at_ms)
95 VALUES (%d, %d, %d, %d)
96 ON DUPLICATE KEY UPDATE
97 last_seen_ms = GREATEST(last_seen_ms, VALUES(last_seen_ms)),
98 last_active_ms = GREATEST(last_active_ms, VALUES(last_active_ms)),
99 inactive_at_ms = GREATEST(inactive_at_ms, VALUES(inactive_at_ms))",
100 $user_id,
101 $record['last_seen_ms'],
102 $record['last_active_ms'],
103 $away ? $record['last_seen_ms'] : 0
104 )
105 );
106 openstation_presence_invalidate_records();
107 return $result;
108 }
109
110 /**
111 * Ensure storage exists and import before publishing the completed checkpoint.
112 *
113 * A connection lock serializes concurrent installers. Upserts and verification
114 * make partial imports retryable without overwriting live timestamps. A short
115 * bridge imports late writes from requests still executing the old plugin.
116 * After five minutes the legacy option is retained but never read or written.
117 *
118 * @internal
119 * @return bool Whether the table is ready for use.
120 */
121 function openstation_presence_migrate_storage() {
122 global $wpdb;
123 $state = get_option( OPENSTATION_PRESENCE_STORAGE_OPTION, array() );
124 if ( ! empty( $state['ready'] ) ) {
125 return true;
126 }
127 $failure_key = 'failed:' . openstation_presence_cache_key();
128 if ( wp_cache_get( $failure_key, 'openstation_presence_request' ) ) {
129 return false;
130 }
131 // Remember failure pessimistically; remove only after verified completion.
132 wp_cache_set( $failure_key, true, 'openstation_presence_request' );
133 openstation_storage_use_primary();
134 $table = openstation_presence_table();
135 $name = 'os-presence-' . md5( $wpdb->dbname . ':' . $table );
136 $lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $name ) );
137 // SQLite's compatibility shim is a no-op; the import is idempotent.
138 if ( ! in_array( (string) $lock, array( '1', '1=1' ), true ) ) {
139 return false;
140 }
141 try {
142 // A worker that waited for the lock must see the winner's checkpoint.
143 wp_cache_delete( OPENSTATION_PRESENCE_STORAGE_OPTION, 'options' );
144 $notoptions = wp_cache_get( 'notoptions', 'options' );
145 if ( is_array( $notoptions ) && isset( $notoptions[ OPENSTATION_PRESENCE_STORAGE_OPTION ] ) ) {
146 unset( $notoptions[ OPENSTATION_PRESENCE_STORAGE_OPTION ] );
147 wp_cache_set( 'notoptions', $notoptions, 'options' );
148 }
149 $state = get_option( OPENSTATION_PRESENCE_STORAGE_OPTION, array() );
150 if ( ! empty( $state['ready'] ) ) {
151 return true;
152 }
153 $collate = $wpdb->get_charset_collate();
154 $suppress = $wpdb->suppress_errors( true );
155 $created = $wpdb->query(
156 "CREATE TABLE IF NOT EXISTS $table (
157 user_id BIGINT UNSIGNED NOT NULL,
158 last_seen_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
159 last_active_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
160 inactive_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
161 PRIMARY KEY (user_id),
162 KEY last_seen_ms (last_seen_ms)
163 ) $collate"
164 );
165 $wpdb->suppress_errors( $suppress );
166 if ( false === $created ) {
167 return false;
168 }
169 $records = openstation_presence_legacy_records();
170 if ( is_wp_error( $records ) ) {
171 return false;
172 }
173 foreach ( $records as $uid => $record ) {
174 if ( ! openstation_presence_upsert( $uid, $record, 0 === $record['last_active_ms'] ) ) {
175 return false;
176 }
177 }
178 // Verify even an empty import against all required columns.
179 $rows = $wpdb->get_results( "SELECT user_id, last_seen_ms, last_active_ms, inactive_at_ms FROM $table", OBJECT_K );
180 if ( '' !== $wpdb->last_error ) {
181 return false;
182 }
183 foreach ( $records as $uid => $record ) {
184 if ( ! isset( $rows[ $uid ] ) || (int) $rows[ $uid ]->last_seen_ms < $record['last_seen_ms'] || (int) $rows[ $uid ]->last_active_ms < $record['last_active_ms'] ) {
185 return false;
186 }
187 }
188 $state = array(
189 'ready' => true,
190 'completed_at_ms' => (int) round( microtime( true ) * 1000 ),
191 'legacy_digest' => md5( serialize( $records ) ),
192 );
193 update_option( OPENSTATION_PRESENCE_STORAGE_OPTION, $state, false );
194 $ready = get_option( OPENSTATION_PRESENCE_STORAGE_OPTION ) === $state;
195 if ( $ready ) {
196 wp_cache_delete( $failure_key, 'openstation_presence_request' );
197 }
198 return $ready;
199 } finally {
200 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
201 }
202 }
203
204 /**
205 * Import late legacy heartbeats during the bounded deployment bridge.
206 *
207 * @internal
208 * @return void
209 */
210 function openstation_presence_migration_tick() {
211 if ( ! openstation_presence_migrate_storage() ) {
212 return;
213 }
214 $state = get_option( OPENSTATION_PRESENCE_STORAGE_OPTION, array() );
215 $cut = (int) ( $state['completed_at_ms'] ?? 0 );
216 $now = (int) round( microtime( true ) * 1000 );
217 if ( $cut <= 0 || $now - $cut > 5 * MINUTE_IN_SECONDS * 1000 ) {
218 return;
219 }
220 $records = openstation_presence_legacy_records();
221 if ( is_wp_error( $records ) ) {
222 return;
223 }
224 $digest = md5( serialize( $records ) );
225 if ( ( $state['legacy_digest'] ?? '' ) === $digest ) {
226 return;
227 }
228 foreach ( $records as $uid => $record ) {
229 if ( $record['last_seen_ms'] > $cut ) {
230 // Old idle heartbeats retain zero activity: they are not fresh away intent.
231 if ( ! openstation_presence_upsert( $uid, $record, false ) ) {
232 return;
233 }
234 }
235 }
236 $state['legacy_digest'] = $digest;
237 update_option( OPENSTATION_PRESENCE_STORAGE_OPTION, $state, false );
238 }
239
240 /**
241 * Read one or all records, retaining the public two-timestamp shape.
242 *
243 * @internal
244 * @param int|null $user_id Restrict to one user, or null for all.
245 * @return array|WP_Error Map keyed by user id.
246 */
247 function openstation_presence_read_records( $user_id = null ) {
248 global $wpdb;
249 $key = openstation_presence_cache_key();
250 $cached = wp_cache_get( $key, 'openstation_presence_request', false, $found );
251 if ( $found ) {
252 return null === $user_id ? $cached : array_intersect_key( $cached, array( $user_id => true ) );
253 }
254 if ( ! openstation_presence_migrate_storage() ) {
255 $records = openstation_presence_legacy_records();
256 if ( ! is_wp_error( $records ) ) {
257 wp_cache_set( $key, $records, 'openstation_presence_request' );
258 }
259 if ( is_wp_error( $records ) || null === $user_id ) {
260 return $records;
261 }
262 return isset( $records[ $user_id ] ) ? array( $user_id => $records[ $user_id ] ) : array();
263 }
264 openstation_storage_use_primary();
265 $table = openstation_presence_table();
266 $sql = "SELECT user_id, last_seen_ms, last_active_ms, inactive_at_ms FROM $table";
267 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table is internal; reuse this request snapshot for user lists.
268 $rows = $wpdb->get_results( $sql, ARRAY_A );
269 if ( '' !== $wpdb->last_error ) {
270 return new WP_Error( 'openstation_presence_read_failed', __( 'Could not read presence.', 'desktop-mode' ) );
271 }
272 $out = array();
273 foreach ( $rows as $row ) {
274 $out[ (int) $row['user_id'] ] = array(
275 'last_seen_ms' => (int) $row['last_seen_ms'],
276 'last_active_ms' => (int) $row['last_active_ms'] > (int) $row['inactive_at_ms'] ? (int) $row['last_active_ms'] : 0,
277 );
278 }
279 wp_cache_set( $key, $out, 'openstation_presence_request' );
280 return null === $user_id ? $out : array_intersect_key( $out, array( $user_id => true ) );
281 }
282
283 /**
284 * Persist one heartbeat, falling back only when storage cannot be installed.
285 *
286 * @internal
287 * @param int $user_id User id.
288 * @param array $record Fresh timestamps (zero activity for an idle heartbeat).
289 * @param bool $away Explicit away intent.
290 * @return bool
291 */
292 function openstation_presence_write_record( $user_id, $record, $away = false ) {
293 if ( openstation_presence_migrate_storage() ) {
294 return openstation_presence_upsert( $user_id, $record, $away );
295 }
296 $all = openstation_presence_legacy_records();
297 if ( is_wp_error( $all ) ) {
298 return false;
299 }
300 $prev = $all[ $user_id ] ?? array(
301 'last_seen_ms' => 0,
302 'last_active_ms' => 0,
303 );
304 $all[ $user_id ] = array(
305 'last_seen_ms' => max( $prev['last_seen_ms'], $record['last_seen_ms'] ),
306 'last_active_ms' => $away ? 0 : max( $prev['last_active_ms'], $record['last_active_ms'] ),
307 );
308 openstation_presence_invalidate_records();
309 return update_option( OPENSTATION_PRESENCE_OPTION, $all, false ) || get_option( OPENSTATION_PRESENCE_OPTION ) === $all;
310 }
311