mysqli_stmt::$num_rows reads 0 without store_result()

Status

Accepted.

Context

BaseRepository::executeStatement() (Src/Library/BaseRepository.php) is the shared helper every GitHub-pipeline Repository (BranchRepository, CheckRunRepository, DiscussionRepository, IssueRepository, PullRequestRepository, ReleaseRepository, ReleaseWorkflowRunRepository, WorkflowRunRepository) uses to prepare, bind, and execute a query. Several of these repositories run an existence check shaped like:

$stmt = $this->executeStatement($sql, 'i', [$id]);
$exists = $stmt->num_rows > 0;   // or: if ($stmt->num_rows === 0) { ... }

mysqli_stmt::$num_rows only reflects the true row count once the result set has been buffered — either via mysqli_stmt::store_result() or mysqli_stmt::get_result(). Read immediately after execute() with neither of those called, it is always 0, regardless of how many rows actually matched. executeStatement() never called either, so every one of these existence checks always believed the row didn’t exist yet — even when it did.

This was silent at the SQL layer: the follow-up INSERT failed on the table’s own unique key (WorkflowRunId, Idx_Repository on RepositoryOwner+RepositoryName, Id, …) and raised a “Duplicate entry” exception. Src/services/consumer.github.database.php’s catch block treats any exception whose message contains “Duplicate entry” as a benign duplicate delivery and ack()s the message without retrying — masking the real failure. The result: rows created on the first delivery for a given entity were correct, but every later delivery for that same entity (a workflow_run’s in_progress/completed follow-ups, a second release for a repository already tracked, …) silently failed to update it.

The concrete symptom that surfaced this: every row in github_release_workflow_runs stayed frozen at Status = 'queued', Conclusion = NULL — the values from each run’s first (requested) delivery — because ReleaseWorkflowRunRepository::exists() never found the row create()d moments earlier, so in_progress/completed deliveries kept re-attempting create() instead of reaching update(). That, combined with ADR-0006’s join-window issue, is why github_release_status_view.WorkflowStatus showed 'Pending' for every release.

Decision

executeStatement() now calls $stmt->store_result() right after a successful execute(), guarded by $stmt->field_count > 0 so it’s a no-op for INSERT/UPDATE/DELETE statements (which have no result set to buffer). This fixes num_rows for every current and future caller in one place, rather than patching each repository’s existence check individually.

Consequences

  • ReleaseWorkflowRunRepository::exists(), ReleaseRepository::getExisting(), CheckRunRepository::exists(), and WorkflowRunRepository::getExisting() now correctly detect an existing row and take the update() branch instead of re-attempting create().
  • Rows already stuck with stale values before this fix shipped do not self-heal: GitHub will not redeliver a workflow_run/release webhook for an event that already happened, so historical rows keep whatever their first delivery wrote until corrected out of band. Only entities that receive a new delivery after this fix went out update correctly going forward.
  • Any future repository built on BaseRepository::executeStatement() that needs a buffered SELECT (existence checks, bind_result()/fetch()) gets correct num_rows/fetch behavior for free — no need to remember to call store_result() at each call site.

This site uses Just the Docs, a documentation theme for Jekyll.