$id, 'name' => $name, ); return $state; } ); } /** * @param string|null $synced_name The product's name when it was synced; null when it was not. */ public static function lane_handled( int $lane, string $run_id, int $gen, int $id, ?string $synced_name ): void { self::write_owned_lane( $lane, $run_id, $gen, static function ( array $state ) use ( $id, $synced_name ): array { $state['current'] = null; ++$state['handled']; // Synced, failed or skipped: the bar moves either way. if ( null !== $synced_name ) { ++$state['updated']; array_unshift( $state['recent'], array( 'id' => $id, 'name' => $synced_name, 'at' => time(), // Orders products synced in the same second; dropped by read(). 'order' => microtime( true ), ) ); $state['recent'] = array_slice( $state['recent'], 0, self::RECENT ); } return $state; } ); } public static function lane_done( int $lane, string $run_id, int $gen ): void { self::write_owned_lane( $lane, $run_id, $gen, static function ( array $state ): array { $state['done'] = true; return $state; } ); } /** * Whether every lane belongs to generation $gen of run $run_id and is done: another run's lanes never are. */ public static function lanes_done( int $gen, string $run_id ): bool { for ( $lane = 0; $lane < self::LANES; $lane++ ) { $state = self::lane( $lane ); if ( $run_id !== $state['run_id'] || $gen !== $state['gen'] || ! $state['done'] ) { return false; } } return true; } /** * Whether lane $lane of generation $gen of run $run_id shows product $id as being written: it started that product * and never got to lane_handled(). A lane that moved on, or another run's lane, is not. */ public static function is_lane_working_on( int $lane, string $run_id, int $gen, int $id ): bool { $state = self::lane( $lane ); return $run_id === $state['run_id'] && $gen === $state['gen'] && ! $state['done'] && null !== $state['current'] && $id === $state['current']['id']; } public static function heartbeat_at(): int { $latest = self::run()['heartbeat_at']; for ( $lane = 0; $lane < self::LANES; $lane++ ) { $latest = max( $latest, self::lane( $lane )['heartbeat_at'] ); } return $latest; } /** * @return array{running: bool, trigger: string, started_at: int, heartbeat_at: int, total: int, updated: int, handled: int, in_progress: array, recent: array, last_synced_at: int, last_updated_count: int, next_sync_at: int|null} */ public static function read(): array { $run = self::run(); $running = $run['running'] && null !== SyncLock::held() && ! SyncLock::is_stale(); $in_progress = array(); $recent = array(); if ( $running ) { for ( $lane = 0; $lane < self::LANES; $lane++ ) { $state = self::lane( $lane ); if ( null !== $state['current'] ) { $in_progress[] = $state['current']; } $recent = array_merge( $recent, $state['recent'] ); } usort( $recent, static function ( array $a, array $b ): int { return $b['order'] <=> $a['order']; } ); $recent = array_map( static function ( array $item ): array { return array( 'id' => $item['id'], 'name' => $item['name'], 'at' => $item['at'], ); }, array_slice( $recent, 0, self::RECENT ) ); } return array( 'running' => $running, 'trigger' => $run['trigger'], 'started_at' => $run['started_at'], 'heartbeat_at' => self::heartbeat_at(), 'total' => $running ? $run['total'] : 0, 'updated' => $running ? self::updated() : 0, 'handled' => $running ? self::handled() : 0, 'in_progress' => $in_progress, 'recent' => $recent, 'last_synced_at' => $run['last_synced_at'], 'last_updated_count' => $run['last_updated_count'], 'next_sync_at' => SyncSchedule::next(), ); } private static function updated(): int { $updated = 0; for ( $lane = 0; $lane < self::LANES; $lane++ ) { $updated += self::lane( $lane )['updated']; } return $updated; } private static function handled(): int { $handled = 0; for ( $lane = 0; $lane < self::LANES; $lane++ ) { $handled += self::lane( $lane )['handled']; } return $handled; } /** * @return array{running: bool, trigger: string, started_at: int, heartbeat_at: int, total: int, last_synced_at: int, last_updated_count: int} */ private static function run(): array { $raw = Options::fresh( Options::SYNC_STATE ); $raw = is_array( $raw ) ? $raw : array(); return array( 'running' => isset( $raw['running'] ) && true === $raw['running'], 'trigger' => isset( $raw['trigger'] ) && 'hourly' === $raw['trigger'] ? 'hourly' : 'button', 'started_at' => isset( $raw['started_at'] ) ? (int) $raw['started_at'] : 0, 'heartbeat_at' => isset( $raw['heartbeat_at'] ) ? (int) $raw['heartbeat_at'] : 0, 'total' => isset( $raw['total'] ) ? (int) $raw['total'] : 0, 'last_synced_at' => isset( $raw['last_synced_at'] ) ? (int) $raw['last_synced_at'] : 0, 'last_updated_count' => isset( $raw['last_updated_count'] ) ? (int) $raw['last_updated_count'] : 0, ); } /** * @param array $run Run fields. */ private static function save_run( array $run ): void { update_option( Options::SYNC_STATE, $run, false ); } /** * @return array{run_id: string, gen: int, done: bool, updated: int, handled: int, current: array{id: int, name: string}|null, recent: array, heartbeat_at: int} */ private static function lane( int $lane ): array { return self::lane_fields( Options::fresh( Options::SYNC_LANE . $lane ) ); } /** * @param mixed $raw A lane option's stored value. * @return array{run_id: string, gen: int, done: bool, updated: int, handled: int, current: array{id: int, name: string}|null, recent: array, heartbeat_at: int} */ private static function lane_fields( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); return array( 'run_id' => isset( $raw['run_id'] ) ? (string) $raw['run_id'] : '', 'gen' => isset( $raw['gen'] ) ? (int) $raw['gen'] : -1, 'done' => isset( $raw['done'] ) && true === $raw['done'], 'updated' => isset( $raw['updated'] ) ? (int) $raw['updated'] : 0, 'handled' => isset( $raw['handled'] ) ? (int) $raw['handled'] : 0, 'current' => isset( $raw['current']['id'], $raw['current']['name'] ) ? array( 'id' => (int) $raw['current']['id'], 'name' => (string) $raw['current']['name'], ) : null, 'recent' => isset( $raw['recent'] ) && is_array( $raw['recent'] ) ? $raw['recent'] : array(), 'heartbeat_at' => isset( $raw['heartbeat_at'] ) ? (int) $raw['heartbeat_at'] : 0, ); } /** * Applies $change to lane $lane only while it belongs to generation $gen of run $run_id, and only if nothing else * wrote the lane since it was read: the new value replaces the row read with one compare-and-swap. A write that * loses (the lane was begun by a run that took over, between this read and this write) is dropped, so an action * left over from an old run can never land in the new run's lane. * * @param callable(array{run_id: string, gen: int, done: bool, updated: int, handled: int, current: array{id: int, name: string}|null, recent: array, heartbeat_at: int}): array $change */ private static function write_owned_lane( int $lane, string $run_id, int $gen, callable $change ): void { $name = Options::SYNC_LANE . $lane; $read = Options::raw( $name ); if ( null === $read ) { return; } $state = self::lane_fields( maybe_unserialize( $read ) ); if ( $run_id !== $state['run_id'] || $gen !== $state['gen'] ) { return; } $state = $change( $state ); $state['heartbeat_at'] = time(); Options::compare_and_swap( $name, $read, maybe_serialize( $state ) ); } /** * @param array $state Lane fields. */ private static function save_lane( int $lane, array $state ): void { update_option( Options::SYNC_LANE . $lane, $state, false ); } private static function clear_lanes(): void { for ( $lane = 0; $lane < self::LANES; $lane++ ) { delete_option( Options::SYNC_LANE . $lane ); } } }