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(), andWorkflowRunRepository::getExisting()now correctly detect an existing row and take theupdate()branch instead of re-attemptingcreate().- Rows already stuck with stale values before this fix shipped do not self-heal: GitHub will not
redeliver a
workflow_run/releasewebhook 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 bufferedSELECT(existence checks,bind_result()/fetch()) gets correctnum_rows/fetch behavior for free — no need to remember to callstore_result()at each call site.