$fields As offered. * @return array|string */ private static function fields_after_last_write( string $product_id, string $writeback_id, array $fields, string $run_id ) { $id = (int) $product_id; $marker = get_post_meta( $id, self::INFLIGHT_META, true ); if ( ! is_array( $marker ) || ! isset( $marker['writeback_id'] ) || $writeback_id !== $marker['writeback_id'] ) { return $fields; } if ( ! self::is_dead( $marker, $run_id ) ) { return 'skipped'; // Another action of this run is writing it right now. } Log::warning( sprintf( 'Approved-product sync: product %d did not finish being written last time.', $id ) ); $in_image_step = ! empty( $marker['image_step'] ); if ( ! $in_image_step || self::MAX_ATTEMPTS !== self::next_attempt( $id, $writeback_id ) ) { $result = self::report_failure( $product_id, $writeback_id, $in_image_step ? 'image_write_failed' : 'save_failed' ); if ( 'failed' === $result ) { delete_post_meta( $id, self::INFLIGHT_META ); } return $result; } Log::warning( sprintf( 'Approved-product sync: gave up on the new image of product %d (died while writing it); its copy goes live without it.', $id ) ); delete_post_meta( $id, self::INFLIGHT_META ); return self::image_only_if_already_set( $id, $fields ); } /** * Writes the fields inside the in-flight marker, remembers the approval as written, and reports the outcome. * * @param array $fields Fields to write. */ private static function write_and_report( string $product_id, string $writeback_id, array $fields, string $run_id ): string { $id = (int) $product_id; $written = null; $reason = null; if ( get_post( $id ) instanceof \WP_Post ) { update_post_meta( $id, self::INFLIGHT_META, array( 'writeback_id' => $writeback_id, 'run_id' => $run_id, 'started_at' => time(), ) ); } try { $last_try = self::MAX_ATTEMPTS === self::next_attempt( $id, $writeback_id ); $written = SyncFieldWriter::write( $id, $fields, $last_try ); } catch ( SyncWriteException $e ) { $reason = $e->reason(); } catch ( \Throwable $e ) { Log::error( sprintf( 'Approved-product sync: product %s hit %s while being written.', $product_id, get_class( $e ) ) ); $reason = 'save_failed'; } finally { delete_post_meta( $id, self::INFLIGHT_META ); // Reached unless the process died. } if ( null === $written ) { return self::report_failure( $product_id, $writeback_id, null === $reason ? 'save_failed' : $reason ); } self::remember_applied( $id, $writeback_id, $written['before'], $written['after'] ); if ( ! SyncApi::synced( $product_id, $writeback_id, $written['before'], $written['after'] ) ) { return 'skipped'; // Written; the next sync is offered it again and reports it, without writing again. } delete_post_meta( $id, self::ATTEMPTS_META ); return 'synced'; } /** * What was written for this approval when it is one of the recent ones written to the product; false when it was * written longer ago than that; null when it was never written. * * @return array{before: array, after: array}|false|null */ private static function applied( int $id, string $writeback_id ) { $stored = get_post_meta( $id, self::APPLIED_META, true ); if ( ! is_array( $stored ) || ! isset( $stored['ids'] ) || ! is_array( $stored['ids'] ) || ! in_array( $writeback_id, $stored['ids'], true ) ) { return null; } foreach ( self::recent( $stored ) as $entry ) { if ( $writeback_id === $entry['writeback_id'] ) { return array( 'before' => $entry['before'], 'after' => $entry['after'], ); } } return false; } /** * The well-formed entries of $stored['recent'], oldest first. A 1.5.0 test build kept only the newest, as 'last'; * it is read as the one recent entry. * * @param array $stored APPLIED_META. * @return array, after: array}> */ private static function recent( array $stored ): array { $recent = isset( $stored['recent'] ) && is_array( $stored['recent'] ) ? $stored['recent'] : array(); if ( ! isset( $stored['recent'] ) && isset( $stored['last'] ) ) { $recent = array( $stored['last'] ); } $out = array(); foreach ( $recent as $entry ) { if ( is_array( $entry ) && isset( $entry['writeback_id'], $entry['before'], $entry['after'] ) && is_string( $entry['writeback_id'] ) && is_array( $entry['before'] ) && is_array( $entry['after'] ) ) { $out[] = array( 'writeback_id' => $entry['writeback_id'], 'before' => $entry['before'], 'after' => $entry['after'], ); } } return $out; } /** * Reports an approval that was already written, without writing it again: one of the recent ones is reported * synced with what was written for it; an older one is reported failed, so the backend stops offering it and #ops * hears about it, instead of it waiting forever. * * @param array{before: array, after: array}|false $applied What applied() found. */ private static function report_again( string $product_id, string $writeback_id, $applied ): string { if ( false === $applied ) { Log::error( sprintf( 'Approved-product sync: product %s was offered an approval written to it long ago and replaced since; nothing written, reported as failed.', $product_id ) ); return self::report_failure( $product_id, $writeback_id, self::verification_reason() ); } Log::warning( sprintf( 'Approved-product sync: product %s was already written for this approval; it is reported again, not written again.', $product_id ) ); if ( ! SyncApi::synced( $product_id, $writeback_id, $applied['before'], $applied['after'] ) ) { return 'skipped'; } delete_post_meta( (int) $product_id, self::ATTEMPTS_META ); return 'synced'; } /** * The failure reason for an answer the plugin will not write because it cannot be the right one: verification_failed * once the backend signs its answers (contract version 2 added that reason), save_failed before that, since an * older backend refuses any reason it does not know and the report would never land. */ private static function verification_reason(): string { return SyncAnswer::signed_only() ? 'verification_failed' : 'save_failed'; } /** * @param array $before Fields as they were. * @param array $after Fields as written. */ private static function remember_applied( int $id, string $writeback_id, array $before, array $after ): void { $stored = get_post_meta( $id, self::APPLIED_META, true ); $stored = is_array( $stored ) ? $stored : array(); $ids = isset( $stored['ids'] ) && is_array( $stored['ids'] ) ? array_values( array_filter( $stored['ids'], 'is_string' ) ) : array(); $ids = array_values( array_diff( $ids, array( $writeback_id ) ) ); $ids[] = $writeback_id; $recent = array_values( array_filter( self::recent( $stored ), static function ( array $entry ) use ( $writeback_id ): bool { return $writeback_id !== $entry['writeback_id']; } ) ); $recent[] = array( 'writeback_id' => $writeback_id, 'before' => $before, 'after' => $after, ); // update_post_meta() unslashes; slash first so the reported values survive exactly. update_post_meta( $id, self::APPLIED_META, wp_slash( array( 'ids' => array_slice( $ids, -self::APPLIED_KEEP ), 'recent' => array_slice( $recent, -self::APPLIED_REPORTABLE ), ) ) ); } /** * Tells a later offer that the write got as far as the image step. Does nothing when no write is in flight. */ public static function mark_image_step( int $id ): void { $marker = get_post_meta( $id, self::INFLIGHT_META, true ); if ( is_array( $marker ) ) { $marker['image_step'] = true; update_post_meta( $id, self::INFLIGHT_META, $marker ); } } /** * $fields without new_image, unless the product's main image is already that image. * * @param array $fields As returned by SyncApi::product(). * @return array */ private static function image_only_if_already_set( int $id, array $fields ): array { $image = isset( $fields['new_image'] ) && is_array( $fields['new_image'] ) ? $fields['new_image'] : array(); if ( ! isset( $image['image_id'] ) || ! SyncImageWriter::is_main_image( $id, (string) $image['image_id'] ) ) { unset( $fields['new_image'] ); } return $fields; } /** * Whether the write that left $marker died: it was left by another run, or is older than a write can take. * * @param array $marker The in-flight marker. */ private static function is_dead( array $marker, string $run_id ): bool { $started = isset( $marker['started_at'] ) ? (int) $marker['started_at'] : 0; return ! isset( $marker['run_id'] ) || $run_id !== $marker['run_id'] || time() - $started > self::INFLIGHT_STALE_AFTER; } private static function report_failure( string $product_id, string $writeback_id, string $reason ): string { $id = (int) $product_id; $exists = get_post( $id ) instanceof \WP_Post; $attempt = $exists ? self::next_attempt( $id, $writeback_id ) : self::MAX_ATTEMPTS; Log::warning( sprintf( 'Approved-product sync: product %d not written (%s), attempt %d of %d.', $id, $reason, $attempt, self::MAX_ATTEMPTS ) ); if ( ! SyncApi::failed( $product_id, $writeback_id, $attempt, $reason ) ) { return 'not_reported'; } if ( $exists ) { update_post_meta( $id, self::ATTEMPTS_META, array( 'writeback_id' => $writeback_id, 'attempts' => $attempt, ) ); } return 'failed'; } private static function next_attempt( int $id, string $writeback_id ): int { $stored = get_post_meta( $id, self::ATTEMPTS_META, true ); $count = is_array( $stored ) && isset( $stored['writeback_id'], $stored['attempts'] ) && $writeback_id === $stored['writeback_id'] ? (int) $stored['attempts'] : 0; return ( $count >= self::MAX_ATTEMPTS ? 0 : $count ) + 1; } }