max_raw_bytes = (int) floor( $ceiling / 4 * 3 ); $this->budget_bytes = $this->correct_budget( $budget_bytes ); $this->fast_streak = max( 0, $fast_streak ); $this->measured_bytes_per_second = max( 0.0, $measured_bytes_per_second ); } /** A job that has not measured anything yet. */ public static function default(): self { return new self( self::DEFAULT_BUDGET_BYTES, 0, 0.0 ); } /** * Rebuild the sizing state a previous worker left behind. Values are corrected on * the way in so a corrupt row cannot yield a refused batch or a zero budget. */ public static function from_state( int $budget_bytes, int $fast_streak, float $measured_bytes_per_second, ?int $encoded_ceiling_bytes = null ): self { return new self( $budget_bytes, $fast_streak, $measured_bytes_per_second, $encoded_ceiling_bytes ); } /** Current raw-bytes budget, before the remaining time is taken into account. */ public function budget_bytes(): int { return $this->budget_bytes; } public function fast_streak(): int { return $this->fast_streak; } public function measured_bytes_per_second(): float { return $this->measured_bytes_per_second; } /** Largest raw batch whose encoded form still fits the receiver's ceiling. */ public function max_raw_bytes(): int { return $this->max_raw_bytes; } /** The encoded-byte ceiling this sizer is sizing against. */ public function encoded_ceiling_bytes(): int { return (int) ceil( $this->max_raw_bytes / 3 ) * 4; } /** * The batch to actually send, given how long the worker has left. The smaller of * the stored budget and what the deadline allows wins, since a budget the link once * met is no guarantee with four seconds remaining. * * @param float|null $deadline Unix timestamp the worker must return by, or null when not time-boxed. * @param int $request_timeout Window this request gets, in seconds. */ public function budget_for( ?float $deadline, int $request_timeout ): int { $window = (float) max( 1, $request_timeout ); if ( null !== $deadline ) { $remaining = $deadline - microtime( true ); if ( $remaining <= 0 ) { return self::MIN_BUDGET_BYTES; } $window = min( $window, $remaining ); } $stored = min( $this->budget_bytes, $this->max_raw_bytes ); // Nothing has been measured, so there is no rate to plan against and the // stored budget stands. if ( $this->measured_bytes_per_second <= 0.0 ) { return $stored; } $usable = max( 0.0, $window * ( 1.0 - self::WINDOW_RESERVE_FRACTION ) ); $afford = (int) floor( $this->measured_bytes_per_second * $usable ); return max( self::MIN_BUDGET_BYTES, min( $stored, $afford ) ); } /** * Record a request that worked. * * @param int $bytes Raw bytes the request carried. * @param float $elapsed_seconds Wall-clock time it took. * @param int $request_timeout Window it was given. */ public function with_success( int $bytes, float $elapsed_seconds, int $request_timeout ): self { $next = clone $this; if ( $bytes > 0 && $elapsed_seconds > 0.0 ) { $sample = $bytes / $elapsed_seconds; $next->measured_bytes_per_second = 0.0 === $this->measured_bytes_per_second ? $sample : ( self::THROUGHPUT_SMOOTHING * $sample ) + ( ( 1.0 - self::THROUGHPUT_SMOOTHING ) * $this->measured_bytes_per_second ); } // Using most of the window means the link is at its limit, and growing // here would find the ceiling by being refused. $used_fraction = $request_timeout > 0 ? $elapsed_seconds / $request_timeout : 1.0; if ( $used_fraction > self::FAST_FRACTION ) { $next->fast_streak = 0; return $next; } $next->fast_streak = $this->fast_streak + 1; if ( $next->fast_streak >= self::FAST_SUCCESSES_TO_GROW ) { $next->fast_streak = 0; $next->budget_bytes = $this->correct_budget( $this->budget_bytes * 2 ); } return $next; } /** * Record a request that did not work. * * The budget is halved so the next attempt differs, and the streak is cleared. * Halving fits because the failure is size-shaped (body over the receiver's * ceiling, or past its window). The floor stops it: at the smallest size the next * failure is the link, not the size. */ public function with_failure(): self { $next = clone $this; $next->fast_streak = 0; $next->budget_bytes = $this->correct_budget( intdiv( $this->budget_bytes, 2 ) ); return $next; } /** * Whether the budget has fallen as far as it can go. At the floor halving is a * no-op, so a further retry only spends the window. */ public function at_floor(): bool { return $this->budget_bytes <= self::MIN_BUDGET_BYTES; } /** * The request size a fresh migration opens at, so it starts where the last one * converged instead of relearning from 1 MB. * * Kept per site, since the same host is the same pipe however many connections * point at it, and per stage, since uploads and plugins are different file * populations. * * @param string $stage Stage key. * * @return int Raw-bytes budget, or the class default when nothing is recorded. */ public static function learned_default( string $stage ): int { $stored = get_option( self::option_name( $stage ), 0 ); // A stored value that is not a positive number counts as no value. A // budget of zero would send nothing at all. if ( ! is_numeric( $stored ) || (int) $stored < 1 ) { return self::DEFAULT_BUDGET_BYTES; } return (int) $stored; } /** * Keep what a run converged on, so the next one starts there. Only a settled value * is stored: a budget that just grew is unproven, and opening at it risks an * immediate refusal. * * @param string $stage Stage key. */ public function remember_as_default( string $stage ): void { update_option( self::option_name( $stage ), $this->budget_bytes(), false ); } /** * The option a stage's learned size is kept in. Public because the store reads it * to decide whether a run improves on what is known. * * @param string $stage Stage key. */ public static function option_name( string $stage ): string { return 'shiftora_file_budget_' . sanitize_key( $stage ); } /** * Keep a stored or computed budget inside the range the receiver will * accept and a request can carry. */ private function correct_budget( int $bytes ): int { return max( self::MIN_BUDGET_BYTES, min( $bytes, $this->max_raw_bytes ) ); } }