jitterSource = $jitterSource !== null ? $jitterSource : static function (int $span): int { return $span > 0 ? random_int(0, $span) : 0; }; $this->taxonomy = $taxonomy instanceof ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy ? $taxonomy : null; } /** * @return ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy Built on first classification. */ private function taxonomy(): ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy { if ($this->taxonomy === null) { $this->taxonomy = new ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy( abj_service('functions')); } return $this->taxonomy; } /** * How long the chain should wait before its next link. * * @param int $consecutiveFailures Failing ticks in a row, 0 on a healthy chain. * @return int Seconds, never above {@see MAX_DELAY_SECONDS}. */ public function secondsUntilNextAttempt(int $consecutiveFailures): int { $failures = max(0, $consecutiveFailures); if ($failures === 0) { return self::BASE_DELAY_SECONDS; } // Doubled by loop rather than by 2 ** $failures: the count comes from a // stored option, and an option that has been corrupted into a large // number must not be able to turn the exponent into an overflow. The // loop stops the moment the ceiling is reached, so the cost is bounded // by the curve, not by the input. $step = self::BASE_DELAY_SECONDS; for ($i = 0; $i < $failures && $step < self::MAX_DELAY_SECONDS; $i++) { $step *= 2; } $step = min(self::MAX_DELAY_SECONDS, $step); $span = intdiv($step, self::JITTER_DIVISOR); if ($span <= 0) { return $step; } // Clamped rather than trusted. A jitter source that answered outside // the span it was asked for would push the delay past the ceiling or // below the previous step, which is exactly what the window exists to // make impossible. $draw = (int)($this->jitterSource)($span); $draw = max(0, min($span, $draw)); return $step - $draw; } /** * Whether a failure is worth coming back for at all. * * Ambiguity resolves to TRUE, in both directions: * * - A failure with no recognizable database error in it (a rebuilder that * threw for its own reasons, a probe whose reason was not reported) * cannot be PROVEN permanent, so it is retried. The * consecutive-failure budget, not a guess about the message, is what * bounds the cost of being wrong. * - A message that reads as both -- a dropped connection whose text also * quotes a missing table -- takes the retryable reading. Guessing * "permanent" on an ambiguous string stops a chain a single retry would * have recovered, which is the more expensive mistake of the two. * * The vocabulary is the shared one the DAO's own retry path uses * ({@see ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy}), so this * cannot drift into a second, private opinion about which database errors * are transient. * * @param string $failureText The failure as reported, driver text included. * @return bool */ public function isWorthRetrying(string $failureText): bool { $text = trim($failureText); if ($text === '') { return true; } $taxonomy = $this->taxonomy(); // Checked first so it wins on a message that matches both readings. if ($taxonomy->connectivity()->isTransientConnectionError($text) || $taxonomy->connectivity()->isDeadlockOrLockTimeoutError($text) || $taxonomy->connectivity()->isCommandsOutOfSyncError($text) || $taxonomy->hostState()->isGaleraConflictError($text)) { return true; } if ($taxonomy->schema()->isMissingPluginTableError($text) || $taxonomy->schema()->isMalformedStatementError($text) || $taxonomy->hostState()->isAccessDeniedError($text)) { return false; } return true; } }