PluginProbe
Blocks – Reusable Content, Shortcodes & Site Variables / trunk
Blocks – Reusable Content, Shortcodes & Site Variables vtrunk
26.09.03.15 26.08.31.21 26.08.22.20 26.08.22.22 26.08.22.17 26.08.22.13 26.08.21.19 26.08.07.23 26.07.19.14 26.07.13.21 26.07.13.17 26.07.12.13 026.07.07.21 026.07.05.18 026.06.26.20 026.06.26.21 026.06.08.20 026.05.13.14 026.04.29.10 trunk 026.02.22.22 026.03.16.23 026.04.23.13
blocks / includes / signls / src / Runtime.php

Runtime.php in Blocks – Reusable Content, Shortcodes & Site Variables trunk, at includes/signls/src/Runtime.php

477 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Product runtime coordinator.
4 *
5 * @package signls-sdk
6 * @license GPL-3.0-or-later
7 */
8
9 namespace Signls\Sdk\V1;
10
11 defined( 'ABSPATH' ) || exit;
12
13 /**
14 * @phpstan-type ProductRuntime array{
15 * adapter: ProductAdapterInterface,
16 * state: StateStore,
17 * consent: Consent,
18 * identity: Identity,
19 * site: SiteIdentity,
20 * scheduler: Scheduler,
21 * transport: Transport
22 * }
23 */
24 final class Runtime {
25
26 private static $products = array();
27
28 private static $site_identity;
29
30 private static $queued_products = array();
31
32 private static $shutdown_registered = false;
33
34 private static $flushing_products = array();
35
36 public static function boot( array $descriptor ): bool {
37 try {
38 $adapter = self::adapter( $descriptor );
39 if ( ! $adapter instanceof ProductAdapterInterface ) {
40 return false;
41 }
42 $product = $adapter->product_slug();
43 if ( isset( self::$products[ $product ] ) ) {
44 return true;
45 }
46
47 $state = new StateStore( $product );
48 $consent = new Consent( $state );
49 $identity = new Identity( $state );
50 $established_signal_state = self::has_established_signal_state( $state );
51 if ( ! self::$site_identity instanceof SiteIdentity ) {
52 self::$site_identity = new SiteIdentity();
53 }
54 $scheduler = new Scheduler( $product, $state, $adapter );
55 $transport = new Transport( $state, $identity, self::$site_identity );
56 $scheduler->register();
57
58 self::$products[ $product ] = array(
59 'adapter' => $adapter,
60 'state' => $state,
61 'consent' => $consent,
62 'identity' => $identity,
63 'site' => self::$site_identity,
64 'scheduler' => $scheduler,
65 'transport' => $transport,
66 );
67
68 if ( $adapter->signal_sharing_enabled() && $consent->enabled() ) {
69 CounterStore::ensure_schema();
70 $scheduler->activate( $adapter->install_id(), $identity->device_id() );
71 self::recognize_product_version( $product, self::$products[ $product ], $established_signal_state );
72 }
73 return true;
74 } catch ( \Throwable $error ) {
75 return false;
76 }
77 }
78
79 public static function enable( string $product, string $source, int $notice_version ): bool {
80 $runtime = self::product( $product );
81 if ( null === $runtime ) {
82 return false;
83 }
84 $established_signal_state = self::has_established_signal_state( $runtime['state'] );
85 if ( ! $runtime['consent']->enable( $source, $notice_version ) ) {
86 return false;
87 }
88 CounterStore::ensure_schema();
89 $runtime['scheduler']->activate( $runtime['adapter']->install_id(), $runtime['identity']->device_id() );
90 self::recognize_product_version( $product, $runtime, $established_signal_state );
91 self::queue_immediate( $product, $runtime );
92 return true;
93 }
94
95 public static function disable( string $product, string $source, int $notice_version ): bool {
96 $runtime = self::product( $product );
97 if ( null === $runtime || ! $runtime['consent']->disable( $source, $notice_version ) ) {
98 return false;
99 }
100 unset( self::$queued_products[ $product ] );
101 $runtime['state']->clear_delivery_intent_unconditionally();
102 $runtime['scheduler']->clear();
103 $runtime['identity']->forget_device();
104 return true;
105 }
106
107 public static function relevant_change( string $product ): void {
108 $runtime = self::product( $product );
109 if ( null !== $runtime && $runtime['consent']->enabled() ) {
110 $runtime['scheduler']->relevant_change();
111 self::queue_immediate( $product, $runtime );
112 }
113 }
114
115 public static function run( string $product ): array {
116 return self::run_internal( $product, 0 );
117 }
118
119 public static function flush_immediate_deliveries(): void {
120 $products = self::$queued_products;
121 self::$queued_products = array();
122 foreach ( $products as $product => $intent ) {
123 self::flush_immediate_internal( (string) $product, (string) $intent );
124 }
125 }
126
127 public static function flush_immediate( string $product ): array {
128 return self::flush_immediate_internal( $product, '' );
129 }
130
131 /**
132 * @return array<string, mixed>
133 */
134 private static function flush_immediate_internal( string $product, string $required_intent ): array {
135 $runtime = self::product( $product );
136 if ( null === $runtime || ! $runtime['consent']->enabled() || ! $runtime['adapter']->signal_sharing_enabled() ) {
137 return self::consent_required_result();
138 }
139 if ( isset( self::$flushing_products[ $product ] ) ) {
140 return array(
141 'ok' => false,
142 'class' => 'delivery_busy',
143 'status' => 0,
144 'permanent' => false,
145 );
146 }
147
148 unset( self::$queued_products[ $product ] );
149 self::$flushing_products[ $product ] = true;
150 try {
151 $result = self::run_internal( $product, 1, $required_intent );
152 $runtime['scheduler']->settle_immediate( $result, '' !== $runtime['state']->delivery_intent() );
153 return $result;
154 } catch ( \Throwable $error ) {
155 $result = array(
156 'ok' => false,
157 'class' => 'runtime_failure',
158 'status' => 0,
159 'permanent' => false,
160 );
161 $runtime['scheduler']->settle_immediate( $result, '' !== $runtime['state']->delivery_intent() );
162 return $result;
163 } finally {
164 unset( self::$flushing_products[ $product ] );
165 }
166 }
167
168 public static function suspend( string $product ): void {
169 $runtime = self::product( $product );
170 unset( self::$queued_products[ $product ] );
171 if ( null !== $runtime ) {
172 $runtime['scheduler']->clear();
173 }
174 }
175
176 private static function run_internal( string $product, int $lock_wait_seconds, string $required_intent = '' ): array {
177 $runtime = self::product( $product );
178 if ( null === $runtime || ! $runtime['consent']->enabled() || ! $runtime['adapter']->signal_sharing_enabled() ) {
179 return self::consent_required_result();
180 }
181
182 $lock = self::acquire_delivery_lock( $product, $lock_wait_seconds );
183 if ( ! $lock['ok'] ) {
184 $runtime['scheduler']->schedule_retry( time() + 60 );
185 return array(
186 'ok' => false,
187 'class' => $lock['class'],
188 'status' => 0,
189 'permanent' => false,
190 );
191 }
192
193 try {
194 $runtime['state']->refresh_concurrency_state();
195 $intent = $runtime['state']->delivery_intent();
196 if ( '' !== $required_intent && ( '' === $intent || ! hash_equals( $required_intent, $intent ) ) ) {
197 return self::superseded_delivery_result();
198 }
199 $observation = $runtime['state']->product_version_observation();
200 $version_transition = self::version_transition_matches_intent(
201 $observation,
202 $intent,
203 $runtime['adapter']->product_version()
204 );
205 if (
206 '' !== $intent
207 && isset( $observation['settled_intent'] )
208 && hash_equals( $observation['settled_intent'], $intent )
209 ) {
210 if ( $runtime['state']->clear_delivery_intent( $intent ) ) {
211 $runtime['state']->clear_settled_product_version_intent( $intent );
212 }
213 return self::superseded_delivery_result();
214 }
215 $deferred = $runtime['transport']->prepare( $runtime['adapter'] );
216 if ( is_array( $deferred ) ) {
217 return $deferred;
218 }
219
220 $had_pending = $runtime['transport']->has_compatible_pending( $runtime['adapter'] );
221 if ( $had_pending ) {
222 $result = $runtime['transport']->deliver( $runtime['adapter'], array() );
223 if ( ! $result['ok'] || '' === $intent ) {
224 self::schedule_failed_delivery( $runtime, $result );
225 return $result;
226 }
227 if ( $version_transition ) {
228 $runtime['state']->settle_product_version_transition( $runtime['adapter']->product_version(), $intent );
229 if ( $runtime['state']->clear_delivery_intent( $intent ) ) {
230 $runtime['state']->clear_settled_product_version_intent( $intent );
231 }
232 return $result;
233 }
234 }
235
236 $payload = Collector::collect( $runtime['adapter'] );
237 $result = $runtime['transport']->deliver( $runtime['adapter'], $payload );
238 if ( '' !== $intent && self::terminal_delivery_result( $result ) ) {
239 $runtime['state']->settle_product_version_transition( $runtime['adapter']->product_version(), $intent );
240 } elseif ( '' === $intent && ! empty( $result['ok'] ) ) {
241 $runtime['state']->baseline_product_version( $runtime['adapter']->product_version() );
242 }
243 if (
244 '' !== $intent
245 && ( $result['ok'] || $runtime['transport']->has_compatible_pending( $runtime['adapter'] ) )
246 && ( ! $version_transition || self::terminal_delivery_result( $result ) )
247 ) {
248 if ( $runtime['state']->clear_delivery_intent( $intent ) ) {
249 $runtime['state']->clear_settled_product_version_intent( $intent );
250 }
251 }
252 self::schedule_failed_delivery( $runtime, $result );
253 return $result;
254 } catch ( \Throwable $error ) {
255 $runtime['scheduler']->schedule_retry( time() + 900 );
256 return array(
257 'ok' => false,
258 'class' => 'runtime_failure',
259 'status' => 0,
260 'permanent' => false,
261 );
262 } finally {
263 self::release_delivery_lock( $lock['name'] );
264 }
265 }
266
267 public static function state( string $product ): array {
268 $runtime = self::product( $product );
269 return null === $runtime ? array() : $runtime['state']->all();
270 }
271
272 public static function cleanup( string $product ): void {
273 $runtime = self::product( $product );
274 unset( self::$queued_products[ $product ], self::$flushing_products[ $product ] );
275 if ( null !== $runtime ) {
276 $runtime['scheduler']->clear();
277 $runtime['state']->delete();
278 }
279 CounterStore::delete_product( $product );
280 }
281
282 private static function adapter( array $descriptor ) {
283 if ( isset( $descriptor['adapter'] ) && $descriptor['adapter'] instanceof ProductAdapterInterface ) {
284 return $descriptor['adapter'];
285 }
286 $class = isset( $descriptor['adapter_class'] ) ? (string) $descriptor['adapter_class'] : '';
287 return '' !== $class && class_exists( $class ) ? new $class() : null;
288 }
289
290 private static function product( string $product ) {
291 return isset( self::$products[ $product ] ) ? self::$products[ $product ] : null;
292 }
293
294 private static function acquire_delivery_lock( string $product, int $wait_seconds ): array {
295 global $wpdb;
296 if (
297 ! is_object( $wpdb )
298 || ! method_exists( $wpdb, 'prepare' )
299 || ! method_exists( $wpdb, 'get_var' )
300 || ! isset( $wpdb->dbname, $wpdb->options )
301 || '' === (string) $wpdb->dbname
302 || '' === (string) $wpdb->options
303 ) {
304 return array(
305 'ok' => false,
306 'class' => 'delivery_lock_unavailable',
307 'name' => '',
308 );
309 }
310
311 $name = 'signls_sdk_delivery_' . substr( hash( 'sha256', (string) $wpdb->dbname . '|' . (string) $wpdb->options . '|' . $product ), 0, 32 );
312 try {
313 $result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,%d)', $name, min( 1, max( 0, $wait_seconds ) ) ) );
314 if ( 1 === (int) $result && '1' === (string) $result ) {
315 return array(
316 'ok' => true,
317 'class' => '',
318 'name' => $name,
319 );
320 }
321 return array(
322 'ok' => false,
323 'class' => '0' === (string) $result ? 'delivery_busy' : 'delivery_lock_unavailable',
324 'name' => '',
325 );
326 } catch ( \Throwable $error ) {
327 return array(
328 'ok' => false,
329 'class' => 'delivery_lock_unavailable',
330 'name' => '',
331 );
332 }
333 }
334
335 private static function release_delivery_lock( string $name ): void {
336 if ( '' === $name ) {
337 return;
338 }
339 global $wpdb;
340 try {
341 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
342 } catch ( \Throwable $error ) {
343 return;
344 }
345 }
346
347 private static function queue_immediate( string $product, array $runtime ): void {
348 $intent = $runtime['state']->mark_delivery_intent();
349 if ( '' === $intent ) {
350 return;
351 }
352 $runtime['state']->rebind_product_version_transition( $intent );
353 self::queue_immediate_intent( $product, $intent );
354 }
355
356 /**
357 * @param ProductRuntime $runtime Product runtime.
358 */
359 private static function queue_existing_immediate( string $product, array $runtime ): void {
360 $intent = $runtime['state']->ensure_delivery_intent();
361 if ( '' === $intent ) {
362 return;
363 }
364 $runtime['state']->rebind_product_version_transition( $intent );
365 self::queue_immediate_intent( $product, $intent );
366 }
367
368 private static function queue_immediate_intent( string $product, string $intent ): void {
369 self::$queued_products[ $product ] = $intent;
370 if ( self::$shutdown_registered ) {
371 return;
372 }
373 self::$shutdown_registered = true;
374 add_action( 'shutdown', array( self::class, 'flush_immediate_deliveries' ), PHP_INT_MAX );
375 }
376
377 /**
378 * @param ProductRuntime $runtime Product runtime.
379 */
380 private static function recognize_product_version( string $product, array $runtime, bool $established_signal_state ): void {
381 $version = $runtime['adapter']->product_version();
382 if (
383 '' === $version
384 || strlen( $version ) > 64
385 || 1 !== preg_match( '/^[\x20-\x7e]+$/D', $version )
386 ) {
387 return;
388 }
389
390 $lock = self::acquire_delivery_lock( $product, 0 );
391 if ( ! $lock['ok'] ) {
392 return;
393 }
394 try {
395 $runtime['state']->refresh_concurrency_state();
396 $observation = $runtime['state']->product_version_observation();
397 $observed = isset( $observation['observed'] ) ? (string) $observation['observed'] : '';
398 $pending = isset( $observation['pending'] ) ? (string) $observation['pending'] : '';
399
400 if ( '' === $observed && '' === $pending && ! $established_signal_state ) {
401 $runtime['state']->baseline_product_version( $version );
402 return;
403 }
404 if ( '' !== $observed && hash_equals( $observed, $version ) && '' === $pending ) {
405 return;
406 }
407
408 $intent = $runtime['state']->ensure_delivery_intent();
409 if ( '' === $intent ) {
410 return;
411 }
412 if ( '' === $pending || ! hash_equals( $pending, $version ) ) {
413 $runtime['state']->begin_product_version_transition( $version, $intent );
414 } else {
415 $runtime['state']->rebind_product_version_transition( $intent );
416 }
417 } finally {
418 self::release_delivery_lock( $lock['name'] );
419 }
420
421 $runtime['scheduler']->relevant_change();
422 self::queue_existing_immediate( $product, $runtime );
423 }
424
425 private static function has_established_signal_state( StateStore $state ): bool {
426 return (int) $state->get( 'last_acknowledged_at', 0 ) > 0
427 || '' !== (string) $state->get( 'device_id', '' )
428 || '' !== (string) $state->get( 'credential_id', '' )
429 || '' !== (string) $state->get( 'pending_body', '' );
430 }
431
432 /**
433 * @param array<string, mixed> $result Delivery result.
434 */
435 private static function terminal_delivery_result( array $result ): bool {
436 return ! empty( $result['ok'] ) || ! empty( $result['permanent'] ) || ! empty( $result['quarantined'] );
437 }
438
439 /**
440 * @param array{observed?: string, pending?: string, intent?: string, settled_intent?: string} $observation Product version observation.
441 */
442 private static function version_transition_matches_intent( array $observation, string $intent, string $version ): bool {
443 return '' !== $intent
444 && isset( $observation['pending'], $observation['intent'] )
445 && hash_equals( (string) $observation['pending'], $version )
446 && hash_equals( (string) $observation['intent'], $intent );
447 }
448
449 /**
450 * @return array<string, mixed>
451 */
452 private static function superseded_delivery_result(): array {
453 return array(
454 'ok' => true,
455 'class' => 'delivery_superseded',
456 'status' => 0,
457 'permanent' => false,
458 'superseded' => true,
459 );
460 }
461
462 private static function schedule_failed_delivery( array $runtime, array $result ): void {
463 if ( ! $result['ok'] && empty( $result['permanent'] ) && empty( $result['quarantined'] ) ) {
464 $runtime['scheduler']->schedule_retry( (int) $runtime['state']->get( 'next_retry_at', time() + 900 ) );
465 }
466 }
467
468 private static function consent_required_result(): array {
469 return array(
470 'ok' => false,
471 'class' => 'consent_required',
472 'status' => 0,
473 'permanent' => true,
474 );
475 }
476 }
477