210 lines
6.2 KiB
PHP
210 lines
6.2 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Draining the queue.
|
||
|
|
*
|
||
|
|
* One row at a time: fetch the source URL, rewrite its links, compare the
|
||
|
|
* result against what is already in the bucket, and upload only if it
|
||
|
|
* differs. The hash comparison is not an optimisation detail — a WordPress
|
||
|
|
* page re-renders byte-identically far more often than it changes, and
|
||
|
|
* without it every post edit would push the home page, every archive and
|
||
|
|
* every asset they reference back over the wire.
|
||
|
|
*
|
||
|
|
* Batches are small and bounded by wall-clock time rather than count, because
|
||
|
|
* the thing that kills a cron-driven exporter is a run that takes longer than
|
||
|
|
* PHP's `max_execution_time` and dies halfway, leaving rows claimed and
|
||
|
|
* nothing to show. Finishing early and picking up next time is always
|
||
|
|
* correct here; the queue is the state.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || exit;
|
||
|
|
|
||
|
|
final class SPE_Runner {
|
||
|
|
|
||
|
|
const CRON_HOOK = 'sirius_press_export_tick';
|
||
|
|
|
||
|
|
/** Stop a batch after this long, whatever is left in the queue. */
|
||
|
|
const TIME_BUDGET = 20;
|
||
|
|
|
||
|
|
public static function hooks() {
|
||
|
|
add_action( self::CRON_HOOK, array( __CLASS__, 'run_batch' ) );
|
||
|
|
add_filter( 'cron_schedules', array( __CLASS__, 'add_schedule' ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function add_schedule( $schedules ) {
|
||
|
|
$schedules['sirius_minute'] = array(
|
||
|
|
'interval' => 60,
|
||
|
|
'display' => __( 'Every minute (Sirius Press export)', 'sirius-press' ),
|
||
|
|
);
|
||
|
|
return $schedules;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function schedule() {
|
||
|
|
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
|
||
|
|
wp_schedule_event( time() + 60, 'sirius_minute', self::CRON_HOOK );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function unschedule() {
|
||
|
|
$timestamp = wp_next_scheduled( self::CRON_HOOK );
|
||
|
|
if ( $timestamp ) {
|
||
|
|
wp_unschedule_event( $timestamp, self::CRON_HOOK );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Process a batch.
|
||
|
|
*
|
||
|
|
* @param int $limit Maximum rows to attempt.
|
||
|
|
* @return array{done:int,failed:int,skipped:int,left:int}
|
||
|
|
*/
|
||
|
|
public static function run_batch( $limit = 10 ) {
|
||
|
|
$result = array(
|
||
|
|
'done' => 0,
|
||
|
|
'failed' => 0,
|
||
|
|
'skipped' => 0,
|
||
|
|
'left' => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
if ( SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||
|
|
// Manual mode: the server has no key, so there is nothing for cron
|
||
|
|
// to do. Rows wait for a browser to sign them.
|
||
|
|
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||
|
|
return $result;
|
||
|
|
}
|
||
|
|
if ( ! SP_Settings::is_configured() ) {
|
||
|
|
return $result;
|
||
|
|
}
|
||
|
|
|
||
|
|
$deadline = microtime( true ) + self::TIME_BUDGET;
|
||
|
|
foreach ( SPE_Queue::claim( $limit ) as $row ) {
|
||
|
|
if ( microtime( true ) > $deadline ) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
$outcome = self::process( $row );
|
||
|
|
if ( 'done' === $outcome ) {
|
||
|
|
$result['done']++;
|
||
|
|
} elseif ( 'skipped' === $outcome ) {
|
||
|
|
$result['skipped']++;
|
||
|
|
} else {
|
||
|
|
$result['failed']++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||
|
|
return $result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* One row.
|
||
|
|
*
|
||
|
|
* @return string 'done', 'skipped' or 'failed'.
|
||
|
|
*/
|
||
|
|
public static function process( $row ) {
|
||
|
|
$body = self::build( $row );
|
||
|
|
if ( is_wp_error( $body ) ) {
|
||
|
|
SPE_Queue::mark_failed( $row->id, $body->get_error_message() );
|
||
|
|
return 'failed';
|
||
|
|
}
|
||
|
|
|
||
|
|
$hash = hash( 'sha256', $body );
|
||
|
|
if ( '' !== $row->content_hash && hash_equals( $row->content_hash, $hash ) ) {
|
||
|
|
// Byte-identical to what is already published.
|
||
|
|
SPE_Queue::mark_done( $row->id, $hash );
|
||
|
|
return 'skipped';
|
||
|
|
}
|
||
|
|
|
||
|
|
$upload = SP_Gateway::put( $row->path, $body );
|
||
|
|
if ( empty( $upload['ok'] ) ) {
|
||
|
|
SPE_Queue::mark_failed( $row->id, $upload['error'] );
|
||
|
|
self::report_failure( $row, $upload['error'] );
|
||
|
|
return 'failed';
|
||
|
|
}
|
||
|
|
|
||
|
|
SPE_Queue::mark_done( $row->id, $hash );
|
||
|
|
/**
|
||
|
|
* Fires after a path is published to the name's bucket.
|
||
|
|
*
|
||
|
|
* @param string $path
|
||
|
|
* @param int $bytes
|
||
|
|
*/
|
||
|
|
do_action( 'sirius_press_exported', $row->path, strlen( $body ) );
|
||
|
|
return 'done';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Produce the bytes for a row.
|
||
|
|
*
|
||
|
|
* Assets are fetched and passed through untouched. Pages are fetched,
|
||
|
|
* rewritten, and the assets they mention are queued behind them — which
|
||
|
|
* is how a bulk export discovers the theme's stylesheet without anybody
|
||
|
|
* having to list it.
|
||
|
|
*
|
||
|
|
* @return string|WP_Error
|
||
|
|
*/
|
||
|
|
public static function build( $row ) {
|
||
|
|
$fetched = SPE_Renderer::fetch( $row->source_url );
|
||
|
|
if ( empty( $fetched['ok'] ) ) {
|
||
|
|
return new WP_Error( 'sirius_export_fetch', $fetched['error'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( 'asset' === $row->kind || SPE_Mapper::is_asset( $row->path ) ) {
|
||
|
|
return $fetched['body'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$rewritten = SPE_Renderer::rewrite( $fetched['body'], $row->path );
|
||
|
|
foreach ( $rewritten['assets'] as $path => $url ) {
|
||
|
|
// Only queue an asset the first time it is seen; re-queueing it on
|
||
|
|
// every page that links the stylesheet would reset its row to
|
||
|
|
// pending forever and the queue would never empty.
|
||
|
|
if ( '' === SPE_Queue::hash_at( $path ) ) {
|
||
|
|
SPE_Queue::add( $path, $url, 'asset' );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $rewritten['html'];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tell somebody when an export stops working.
|
||
|
|
*
|
||
|
|
* Only on the attempt that exhausts the retries, and only once per hour:
|
||
|
|
* a misconfigured key fails on every row in the queue, and an inbox with
|
||
|
|
* four hundred copies of the same message is indistinguishable from no
|
||
|
|
* message at all.
|
||
|
|
*/
|
||
|
|
private static function report_failure( $row, $error ) {
|
||
|
|
if ( (int) $row->attempts + 1 < SPE_Queue::MAX_ATTEMPTS ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if ( get_transient( 'sirius_export_failure_notified' ) ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
set_transient( 'sirius_export_failure_notified', 1, HOUR_IN_SECONDS );
|
||
|
|
|
||
|
|
SP_Inbox::add(
|
||
|
|
0,
|
||
|
|
__( 'A page could not be published to your name', 'sirius-press' ),
|
||
|
|
sprintf(
|
||
|
|
/* translators: 1: bucket path, 2: error message, 3: URL of the export screen. */
|
||
|
|
wp_kses_post( __( '<code>%1$s</code> failed to upload: %2$s<br><br>Other pages may be failing for the same reason. <a href="%3$s">Open the export screen</a> to see the queue.', 'sirius-press' ) ),
|
||
|
|
esc_html( $row->path ),
|
||
|
|
esc_html( $error ),
|
||
|
|
esc_url( admin_url( 'admin.php?page=sirius-press-export' ) )
|
||
|
|
),
|
||
|
|
'export'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Queue every exportable page on the site. */
|
||
|
|
public static function queue_full_site() {
|
||
|
|
$queued = 0;
|
||
|
|
foreach ( SPE_Mapper::full_site() as $path => $target ) {
|
||
|
|
if ( SPE_Queue::add( $path, $target['url'], $target['kind'] ) ) {
|
||
|
|
$queued++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $queued;
|
||
|
|
}
|
||
|
|
}
|