dbCore = $dbCore; $this->logger = $logger; $this->entrySanitizer = $entrySanitizer; $this->recovery = $recovery; } /** * @param array> $queue * @param-out array> $queue * @param callable $flushCallback * @param array $entry */ public function queueLogEntry(array &$queue, bool &$shutdownHookRegistered, callable $flushCallback, array $entry): void { $queue[] = $entry; if (!$shutdownHookRegistered) { $shutdownHookRegistered = true; add_action('shutdown', $flushCallback, 9); } } /** * @param array> $queue */ public function flushLogQueue(array &$queue, bool &$shutdownHookRegistered, bool &$isFlushingLogQueue): void { if ($isFlushingLogQueue) { return; } $isFlushingLogQueue = true; $batchDatabaseError = ''; try { if (empty($queue)) { return; } global $wpdb; $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}'); $validatedColumns = $this->insertColumns($queue[0], $tableName); if (empty($validatedColumns)) { return; } $columnList = '`' . implode('`, `', $validatedColumns) . '`'; $sanitizedEntries = []; foreach ($queue as $entry) { $entryColumns = array_keys($entry); $missingCols = array_diff($validatedColumns, array_merge($entryColumns, array('url_sort_key'))); if (!empty($missingCols)) { continue; } $sanitized = $this->entrySanitizer->sanitizeLogEntry($entry); if ($sanitized === null) { continue; } $sanitizedEntries[] = $sanitized; } if (empty($sanitizedEntries)) { return; } list($formats, $flattenedValues) = $this->buildValuePlaceholders($sanitizedEntries, $validatedColumns); $sql = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES " . implode(', ', $formats); // DAO-bypass-approved: queue flusher batches dynamic INSERT placeholders and must inspect wpdb last_error on the same connection. $prepared = $wpdb->prepare($sql, $flattenedValues); $this->dbCore->connectionManager()->resetForRetry(); // DAO-bypass-approved: batch log insert must preserve same-handle last_error for recovery classification. $result = ABJ_404_Solution_DatabaseDriverCall::query($wpdb, $prepared); $this->recovery->clearLogsv2FullNoticeAfterWrite([ 'result' => $result, 'rows_affected' => isset($wpdb->rows_affected) ? $wpdb->rows_affected : null, 'last_error' => isset($wpdb->last_error) ? $wpdb->last_error : null, ]); if ($result === false && !empty($wpdb->last_error)) { $batchDatabaseError = (string)$wpdb->last_error; $this->recoverFailedBatch($tableName, $columnList, $prepared, $sanitizedEntries, $validatedColumns, $batchDatabaseError); } } catch (\Throwable $e) { // This runs on the 'shutdown' action, which fires on every // request that queued a log entry. finally (below) already // resets the queue/flags; without this catch, an uncaught // throwable here would propagate out of do_action('shutdown') // and abort any other plugin's later shutdown cleanup too, not // just lose this batch of log rows. $failureMessage = 'flushLogQueue failed: ' . get_class($e) . ': ' . $e->getMessage(); if ($batchDatabaseError !== '' && $this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($batchDatabaseError)) { $this->logger->warn($failureMessage . ' | database_error=' . $batchDatabaseError); } else { $this->logger->errorMessage( $failureMessage, $e instanceof \Exception ? $e : null ); } } finally { $queue = []; $shutdownHookRegistered = false; $isFlushingLogQueue = false; } } /** * Choose only syntactically valid, available columns. Sanitization owns the * URL key, so queue producers need not know it; legacy schemas omit it. * @param array $entry * @return array */ private function insertColumns(array $entry, string $tableName): array { $columns = array(); foreach (array_keys($entry) as $column) { if (preg_match('/^[a-z_][a-z0-9_]*$/i', $column)) { $columns[] = $column; } } $schema = $this->dbCore->tableNameResolver()->getTableColumnNames($tableName); if ($schema !== array()) { $columns = array_intersect($columns, $schema); } if (in_array('url_sort_key', $schema, true) && !in_array('url_sort_key', $columns, true)) { $columns[] = 'url_sort_key'; } return array_values($columns); } /** * @param array> $entries * @param array $validatedColumns * @return array{0: array, 1: array} */ private function buildValuePlaceholders(array $entries, array $validatedColumns): array { $formats = []; $flattenedValues = []; foreach ($entries as $entry) { $rowFormats = []; foreach ($validatedColumns as $col) { $value = $entry[$col]; if ($value === null) { $rowFormats[] = 'NULL'; continue; } if (is_int($value)) { $rowFormats[] = '%d'; } else { $rowFormats[] = '%s'; } $flattenedValues[] = $value; } $formats[] = '(' . implode(', ', $rowFormats) . ')'; } return array($formats, $flattenedValues); } /** * @param array> $sanitizedEntries * @param array $validatedColumns */ private function recoverFailedBatch( string $tableName, string $columnList, string $prepared, array $sanitizedEntries, array $validatedColumns, string $batchError ): void { global $wpdb; if ($this->recovery->isTableFullError($batchError)) { $trimmed = $this->recovery->autoTrimLogsv2IfNeeded($tableName, $batchError); if ($trimmed) { if ($this->dbCore->connectionManager()->resetForRetry($batchError)) { // DAO-bypass-approved: table-full recovery retries the already-prepared batch on the same wpdb connection. $retryResult = ABJ_404_Solution_DatabaseDriverCall::query($wpdb, $prepared); $this->recovery->clearLogsv2FullNoticeAfterWrite([ 'result' => $retryResult, 'rows_affected' => isset($wpdb->rows_affected) ? $wpdb->rows_affected : null, 'last_error' => isset($wpdb->last_error) ? $wpdb->last_error : null, ]); if ($retryResult !== false) { return; } $batchError = (string)$wpdb->last_error; } } $this->recovery->setLogsv2FullNotice($batchError); } $commandsOutOfSync = $this->dbCore->errorClassifier() ->taxonomy() ->connectivity() ->isCommandsOutOfSyncError($batchError); if ($commandsOutOfSync && $this->dbCore->connectionManager()->resetForRetry($batchError)) { // DAO-bypass-approved: centralized connection recovery drained error 2014 before this retry. $retryResult = ABJ_404_Solution_DatabaseDriverCall::query($wpdb, $prepared); $this->recovery->clearLogsv2FullNoticeAfterWrite([ 'result' => $retryResult, 'rows_affected' => isset($wpdb->rows_affected) ? $wpdb->rows_affected : null, 'last_error' => isset($wpdb->last_error) ? $wpdb->last_error : null, ]); if ($retryResult !== false) { $context = $this->recovery->getWpdbRecentQueryContextForLogs(); $suffix = ($context !== '') ? " | savequeries_context={$context}" : ''; $this->logger->warn("flushLogQueue batch INSERT recovered on the shared DB connection.{$suffix}"); return; } $batchError = (string)$wpdb->last_error; } $this->recoverEntriesIndividually($tableName, $columnList, $sanitizedEntries, $validatedColumns, $batchError); } /** * @param array> $sanitizedEntries * @param array $validatedColumns */ private function recoverEntriesIndividually( string $tableName, string $columnList, array $sanitizedEntries, array $validatedColumns, string $batchError ): void { global $wpdb; $successCount = 0; $failCount = 0; $failureDetails = []; $lastSuccessfulWriteEvidence = null; foreach ($sanitizedEntries as $index => $entry) { $rowFormats = []; $rowValues = []; foreach ($validatedColumns as $col) { $value = $entry[$col]; if ($value === null) { $rowFormats[] = 'NULL'; } else { $rowFormats[] = is_int($value) ? '%d' : '%s'; $rowValues[] = $value; } } $rowPlaceholder = '(' . implode(', ', $rowFormats) . ')'; /** @var literal-string $singleSqlTemplate */ $singleSqlTemplate = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES {$rowPlaceholder}"; // DAO-bypass-approved: row-level fallback reuses wpdb prepare/query so recovery can classify last_error per row. $singleSql = $wpdb->prepare($singleSqlTemplate, $rowValues); $connectionReady = $this->dbCore->connectionManager()->resetForRetry($batchError); // DAO-bypass-approved: row-level retry must preserve same-handle last_error for per-row recovery. $singleResult = $connectionReady ? ABJ_404_Solution_DatabaseDriverCall::query($wpdb, (string)$singleSql) : false; if ($singleResult === false) { $lastError = !empty($wpdb->last_error) ? (string)$wpdb->last_error : 'Connection reset failed before the row-level retry could execute.'; $failCount++; $payload = function_exists('wp_json_encode') ? wp_json_encode($entry) : json_encode($entry); if (is_string($payload) && strlen($payload) > 1024) { $payload = substr($payload, 0, 1024) . '...'; } $failureDetails[] = ['index' => $index, 'error' => $lastError, 'payload' => $payload]; } else { $successCount++; $lastSuccessfulWriteEvidence = [ 'result' => $singleResult, 'rows_affected' => isset($wpdb->rows_affected) ? $wpdb->rows_affected : null, 'last_error' => isset($wpdb->last_error) ? $wpdb->last_error : null, ]; } } if ($failCount > 0) { $detailsParts = []; foreach (array_slice($failureDetails, 0, 3) as $detail) { $detailsParts[] = "entry {$detail['index']}: {$detail['error']} | payload={$detail['payload']}"; } $detailsSuffix = count($failureDetails) > 3 ? ' | (additional failures omitted)' : ''; $context = $this->recovery->getWpdbRecentQueryContextForLogs(); $contextSuffix = ($context !== '') ? (" | savequeries_context=" . $context) : ''; if ($this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($batchError)) { $this->logger->warn("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix); } else { $this->logger->errorMessage("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix); } } else { if (is_array($lastSuccessfulWriteEvidence)) { $this->recovery->clearLogsv2FullNoticeAfterWrite($lastSuccessfulWriteEvidence); } $this->logger->warn("flushLogQueue batch INSERT failed but recovered: all {$successCount} entries inserted individually. | batch_error=" . $batchError); } } }