operations = $operations; $this->runs = $runs ? $runs : new RunRepository(); $this->items = $items ? $items : new RunItemRepository(); } /** * Run repository. * * @return RunRepository */ public function runs() { return $this->runs; } /** * Item repository. * * @return RunItemRepository */ public function items() { return $this->items; } /** * Creates a dry-run: validates, snapshots the cohort and computes the preview * (inline for small cohorts, in the background otherwise). * * @param CohortSpec $spec Cohort. * @param string $operation_id Operation. * @param array $params Raw params. * @param int $user_id Creator. * @param array $options relation, parent_run_id, inline (force inline), suppress_emails. * @return Run|\WP_Error */ public function create( CohortSpec $spec, $operation_id, array $params, $user_id, array $options = array() ) { try { $operation = $this->operations->get( $operation_id ); } catch ( UnknownOperationException $e ) { return new \WP_Error( 'recast_unknown_operation', __( 'Unknown change.', 'recast-subscriptions' ), array( 'status' => 400 ) ); } // Undo batches come only from UndoPlanner, which checks the source batch first. It is the // only caller that sets the undo relation; REST and CLI previews never do. $relation = isset( $options['relation'] ) ? $options['relation'] : Run::RELATION_OPERATION; if ( 'journal' === $operation->describe()->group && Run::RELATION_UNDO !== $relation ) { return new \WP_Error( 'recast_internal_operation', __( 'You cannot select this change. To undo a batch, open the batch and select Undo.', 'recast-subscriptions' ), array( 'status' => 400 ) ); } $validation = $operation->validate( $params ); if ( ! $validation->is_valid() ) { return $validation->to_wp_error(); } $params = $validation->params(); $estimate = ( new Counter() )->count( $spec, true ); $max = (int) Settings::get( 'recast_max_cohort_size' ); if ( (int) $estimate['count'] > $max ) { return new \WP_Error( 'recast_cohort_too_large', sprintf( /* translators: 1: cohort size, 2: maximum */ __( 'The selection has %1$d subscriptions. The maximum per batch is %2$d. Add filters to make the selection smaller.', 'recast-subscriptions' ), (int) $estimate['count'], $max ), array( 'status' => 400 ) ); } if ( 'complete' === $estimate['status'] && 0 === (int) $estimate['count'] ) { return new \WP_Error( 'recast_cohort_empty', __( 'No subscriptions match the selection.', 'recast-subscriptions' ), array( 'status' => 400 ) ); } $run = new Run(); $run->relation = $relation; $run->parent_run_id = isset( $options['parent_run_id'] ) ? (int) $options['parent_run_id'] : null; $run->status = RunStatus::RESOLVING; $run->operation_id = $operation_id; $run->operation_params = $params; $run->cohort_spec = $spec->to_array(); $run->cohort_hash = $spec->hash(); $run->fingerprint_fields = Fingerprint::fields_for( $operation->touched_fields( $params ), $operation_id, $params ); $run->fp_version = Fingerprint::VERSION; $run->chunk_size = (int) Settings::get( 'recast_apply_chunk_size' ); $run->lanes = (int) Settings::get( 'recast_apply_lanes' ); $run->created_by = (int) $user_id; $run->summary = array( 'counts' => array_fill_keys( ItemStatus::all(), 0 ), 'total' => 0, 'estimate' => (int) $estimate['count'], 'money' => array(), 'warnings' => array(), 'suppress_emails' => array_key_exists( 'suppress_emails', $options ) ? (bool) $options['suppress_emails'] : in_array( 'suppress_emails_default', $operation->describe()->flags, true ), ); $this->runs->insert( $run ); $this->runs->event( $run->id, 'run_created', '', array( 'operation' => $operation_id, 'estimate' => $estimate['count'], ) ); $inline = empty( $options['background'] ) && ( ! empty( $options['inline'] ) || ( $estimate['exact'] && (int) $estimate['count'] <= self::INLINE_LIMIT ) || ! Scheduler::available() ); if ( $inline ) { $this->resolve( $run, PHP_INT_MAX ); $this->preview( $run, PHP_INT_MAX ); return $this->runs->get( $run->id ); } Scheduler::enqueue( Scheduler::HOOK_RUN_RESOLVE, array( $run->id ) ); return $run; } /** * Resolves the cohort into snapshot rows, resuming from the stored cursor. Returns true when done. * * @param Run $run Run. * @param float $budget Seconds. * @return bool Whether resolution finished. */ public function resolve( Run $run, $budget ) { $spec = $run->spec(); if ( ! $spec ) { $this->fail( $run, 'invalid_spec' ); return true; } $deadline = microtime( true ) + $budget; $cursor = $run->resolve_cursor(); $position = $this->items->total( $run->id ); $buffer = array(); $stopped = false; $query = new CohortQuery( $spec ); $query->iterate( function ( \WC_Subscription $subscription ) use ( &$buffer, &$position, &$stopped, $deadline, $run ) { $buffer[] = (int) $subscription->get_id(); if ( count( $buffer ) >= 200 ) { $this->items->insert_items( $run, $buffer, $position ); $position += count( $buffer ); $buffer = array(); } if ( microtime( true ) > $deadline ) { $stopped = true; return false; } return true; }, $cursor ); if ( $buffer ) { $this->items->insert_items( $run, $buffer, $position ); } $total = $this->items->total( $run->id ); $max = (int) Settings::get( 'recast_max_cohort_size' ); if ( $total > $max ) { $this->fail( $run, sprintf( 'cohort_too_large:%d', $max ) ); return true; } $this->runs->patch_summary( $run->id, array( 'total' => $total, 'resolve_cursor' => $stopped ? $cursor : array(), ) ); if ( $stopped ) { return false; } if ( 0 === $total ) { $this->fail( $run, 'cohort_empty' ); return true; } $this->runs->cas_status( $run->id, RunStatus::RESOLVING, RunStatus::PREVIEWING ); $this->runs->event( $run->id, 'resolved', '', array( 'total' => $total ) ); return true; } /** * Computes previews for pending items within a budget. Returns true when none remain. * * @param Run $run Run. * @param float $budget Seconds. * @return bool */ public function preview( Run $run, $budget ) { $operation = $this->operations->get( $run->operation_id ); $deadline = microtime( true ) + $budget; $run = $this->runs->get( $run->id ); $money = isset( $run->summary['money'] ) ? $run->summary['money'] : array(); $warnings = isset( $run->summary['warnings'] ) ? $run->summary['warnings'] : array(); $done = false; $processed = 0; while ( true ) { $batch = $this->items->batch( $run->id, ItemStatus::PENDING, 100 ); if ( empty( $batch ) ) { $done = true; break; } foreach ( $batch as $item ) { $this->preview_item( $run, $operation, $item, $money, $warnings ); ++$processed; if ( microtime( true ) > $deadline ) { break 2; } } if ( 0 === $processed % 500 && function_exists( 'wp_cache_flush_runtime' ) ) { wp_cache_flush_runtime(); } } $summary = $this->runs->patch_summary( $run->id, array( 'counts' => $this->items->counts( $run->id ), 'total' => $this->items->total( $run->id ), 'money' => $money, 'warnings' => $warnings, ) ); if ( $done ) { $this->finalize_preview( $run, $summary ); } return $done; } /** * Computes and stores one item's preview. * * @param Run $run Run. * @param OperationInterface $operation Operation. * @param RunItem $item Item. * @param array $money Money tally (by reference). * @param array $warnings Warning tally (by reference). * @return RunItem */ public function preview_item( Run $run, OperationInterface $operation, RunItem $item, array &$money, array &$warnings ) { $subscription = wcs_get_subscription( $item->subscription_id ); if ( ! $subscription instanceof \WC_Subscription ) { $item->status = ItemStatus::INVALID; $item->error = array( 'code' => 'not_found', 'message' => __( 'Subscription no longer exists.', 'recast-subscriptions' ), ); $this->items->save_preview( $item ); return $item; } try { $diff = $operation instanceof \Recast\WCS\Operation\AbstractOperation ? $operation->compute_diff_guarded( $subscription, $run->operation_params ) : $operation->compute_diff( $subscription, $run->operation_params ); } catch ( \Throwable $e ) { Logger::error( 'Preview failed for subscription ' . $item->subscription_id, array( 'run' => $run->uuid, 'error' => $e->getMessage(), ) ); $item->status = ItemStatus::INVALID; $item->error = array( 'code' => 'exception', 'message' => $e->getMessage(), ); $this->items->save_preview( $item ); return $item; } $item->before_state = $diff->before_state ? $diff->before_state : StateExtractor::extract( $subscription, $run->fingerprint_fields ); $item->before_hash = Fingerprint::compute( $item->before_state ); $item->after_state = $diff->after_state ? $diff->after_state : $item->before_state; $item->after_hash = Fingerprint::compute( $item->after_state ); $item->diff = $diff->changes_to_array(); $item->warnings = $diff->warnings; $item->error = null; foreach ( $diff->warnings as $warning ) { $warnings[ $warning['code'] ] = isset( $warnings[ $warning['code'] ] ) ? $warnings[ $warning['code'] ] + 1 : 1; } if ( $diff->is_skipped() ) { $item->status = ItemStatus::INVALID; $item->error = array( 'code' => $diff->skip_reason, 'message' => $diff->skip_message, ); } elseif ( $diff->has_changes() || 'noop' === $operation->id() ) { $item->status = ItemStatus::READY; $this->tally_money( $money, $item->before_state, $item->after_state ); } else { $item->status = ItemStatus::NO_CHANGE; } $this->items->save_preview( $item ); return $item; } /** * Adds an item's recurring total delta to the per-currency tally. * * @param array $money Tally. * @param array $before Before state. * @param array $after After state. * @return void */ private function tally_money( array &$money, array $before, array $after ) { if ( ! isset( $before['total'] ) || ! isset( $after['total'] ) ) { return; } $currency = isset( $before['currency'] ) ? (string) $before['currency'] : get_woocommerce_currency(); if ( ! isset( $money[ $currency ] ) ) { $money[ $currency ] = array( 'before' => '0', 'after' => '0', 'delta' => '0', ); } $money[ $currency ]['before'] = Money::add( $money[ $currency ]['before'], $before['total'] ); $money[ $currency ]['after'] = Money::add( $money[ $currency ]['after'], $after['total'] ); $money[ $currency ]['delta'] = Money::sub( $money[ $currency ]['after'], $money[ $currency ]['before'] ); } /** * Marks the run previewed with expiry and required confirmations. * * @param Run $run Run. * @param array $summary Summary. * @return void */ private function finalize_preview( Run $run, array $summary ) { $ttl = max( 1, (int) Settings::get( 'recast_preview_ttl_hours' ) ); $summary['confirmation_required'] = Thresholds::required( $summary, $run ); unset( $summary['resolve_cursor'] ); $now = current_time( 'mysql', true ); $this->runs->update( $run->id, array( 'summary' => $summary, 'previewed_at' => $now, 'expires_at' => gmdate( 'Y-m-d H:i:s', strtotime( $now . ' UTC' ) + $ttl * HOUR_IN_SECONDS ), ) ); $previewed = $this->runs->cas_status( $run->id, array( RunStatus::PREVIEWING, RunStatus::RESOLVING ), RunStatus::PREVIEWED ); $this->runs->event( $run->id, 'previewed', '', array( 'counts' => $summary['counts'] ) ); if ( ! $previewed ) { return; } /** * Fires when a batch preview is ready. * * @param Run $run Batch. */ do_action( 'recast_run_previewed', $this->runs->get( $run->id ) ); } /** * Cancels a run that has not started applying. * * @param Run $run Run. * @return bool */ public function cancel( Run $run ) { $ok = $this->runs->cas_status( $run->id, array( RunStatus::RESOLVING, RunStatus::PREVIEWING, RunStatus::PREVIEWED, RunStatus::DRAFT ), RunStatus::CANCELLED, array( 'finished_at' => current_time( 'mysql', true ) ) ); if ( $ok ) { $this->items->transition_all( $run->id, array( ItemStatus::PENDING, ItemStatus::READY ), ItemStatus::SKIPPED, array( 'code' => 'cancelled' ) ); $this->runs->event( $run->id, 'cancelled' ); } return $ok; } /** * Marks a run failed. * * @param Run $run Run. * @param string $message Message. * @return void */ public function fail( Run $run, $message ) { $this->runs->update( $run->id, array( 'status' => RunStatus::FAILED, 'last_error' => $message, 'finished_at' => current_time( 'mysql', true ), ) ); $this->runs->event( $run->id, 'failed', $message, array(), 'error' ); } }