fix(notifications): stop the event queue busy-looping on a slow sink
What does this MR do?
The bufferer goroutine of eventQueue in notifications/sinks.go never blocks while it holds events that the sender has not taken yet. The select of its main loop (sinks.go#L299-324) has an empty default: case, so when the sender is still busy writing the previous event and no new event arrives, the select returns at once and the loop starts over. While a notification endpoint is slow or unreachable, the backoff sink keeps the sender busy through every retry, and the registry keeps a full CPU core busy for each endpoint in that state. The loop that drains the queue on Close (sinks.go#L334-344) has the same empty default: and spins for up to queuepurgetimeout.
Neither loop has anything else to do while it waits, so this MR removes both default: cases and lets each select block until one of its channels is ready. Both empty cases came with 4d0e4639 (fix: make eventqueue sink interruptable, 2024-09-25).
With a registry built from master, one notification endpoint on a port nothing listens on, and one blob pushed and pulled three times, the registry process used 5020 ms of CPU time over the next 5 seconds; built from this branch, 0 ms. With a second endpoint like the first, three samples of 5 seconds read 9630 to 9990 ms from master and 0 ms from this branch. The steps are in the issue.
It is two commits:
test(notifications): wait for the sender in the exact queue limit test. Therespects exact queue size limitsubtest writesmaxQueueSize+1events to a blocked sink and expects none of them to be dropped, which only holds once the sender has taken the first event, and nothing makes the subtest wait for that. It passes onmaster, but with the fix alone it failed in 3 to 15 of 50 runs, depending on the machine.blockableSinknow reports when aWritestarts waiting, and the subtest waits for the first one before it fills the queue. This commit passes onmasteras it is.fix(notifications): stop the event queue busy-looping on a slow sink, withTestEventQueueBuffererWaitsForBusySink. With the sink blocked and one event waiting, it checks that every bufferer goroutine is parked on a channel, once during normal operation and once whileClosedrains the queue, and that both events reach the sink once the sink is released. It reads the goroutine state fromruntime.Stackrather than measuring CPU time, so that the result does not depend on the load of the CI runner.
Nothing else changes: when a new event and the sender are both ready, the select still picks one of them at random, as it did before, and the purge timeout still bounds the drain on Close.
Closes #2987 (closed)
The new test, and how it fails on master
On master at 2f9d3333 with the test changes of this MR but without the change to sinks.go, on Go 1.26.8, trimmed to the assertion lines (Go 1.25.10 and 1.27.2 print the same):
=== RUN TestEventQueueBuffererWaitsForBusySink/during_normal_operation
Error: Should be true
Messages: bufferer goroutine is "runnable" instead of waiting on a channel: [runnable]
Error: Condition never satisfied
=== RUN TestEventQueueBuffererWaitsForBusySink/while_draining_on_close
Error: Should be true
Messages: bufferer goroutine is "runnable" instead of waiting on a channel: [runnable]
Error: Condition never satisfied
--- FAIL: TestEventQueueBuffererWaitsForBusySink (10.00s)
--- FAIL: TestEventQueueBuffererWaitsForBusySink/during_normal_operation (5.00s)
--- FAIL: TestEventQueueBuffererWaitsForBusySink/while_draining_on_close (5.00s)On this branch both subtests pass, and 100 runs in a row pass. Putting back either default: case alone fails the matching subtest and only that one. Taking the first commit's wait out again, with the fix in place, makes the exact limit subtest fail again in some of 50 runs.
Checks I ran locally
go test -count=1 ./notifications/...on Go 1.25.10, 1.26.8 and 1.27.2 (the CI matrix): pass. Each commit on its own, withgo vet, on Go 1.26.8: pass.go test -race -count=1 ./notifications/...: pass.TestEventQueue,TestEventQueueMaxSizeand the new test under-racewith-count=20: pass.TestEventQueueMaxSize/respects_exact_queue_size_limitwith-count=50onmaster, on the first commit and on this branch: pass.- The
api:conformancesuite (integration,api_conformance_test, no database), which runs every case again with webhook notifications enabled: pass. - The same suite with the metadata database, as the
database:api-conformancejob runs it, against the project'spostgresql-ci:17image: pass. golangci-lint runwith v2.13.1 and the project's.golangci.yml, on the whole repository for amd64 and on./notifications/...for 386, arm and arm64: 0 issues.gofmt -l,go vetandgo build ./...: clean.commitlintwith@commitlint/config-conventionalinnode:lts-alpineon both commits: 0 problems.- The registry built from
masterand from this branch, run through the steps in the issue: 5020 ms and 0 ms of CPU time; with a second unreachable endpoint, 9630 to 9990 ms and 0 ms.
Author checklist
- CODEOWNERS Review: This MR requires approval from at least one CODEOWNER per category/file.
- If codeowners are absent or the change is urgent, any registry maintainer can temporarily disable CODEOWNERS reviews in the project settings. When doing so:
- Add a comment on the MR justifying the bypass
- Mention/CC the designated codeowners in the MR for async review
- Re-enable CODEOWNERS reviews in project settings once the MR is merged
- If you lack permissions to disable CODEOWNERS reviews, reach out to the Engineering Manager (
@jaime) or Senior Engineering Manager (@crystalpoole) for assistance.
- If codeowners are absent or the change is urgent, any registry maintainer can temporarily disable CODEOWNERS reviews in the project settings. When doing so:
- Assign one of conventional-commit prefixes to the MR.
-
fix: Indicates a bug fix, triggers a patch release. -
feat: Signals the introduction of a new feature, triggers a minor release. -
perf: Focuses on performance improvements that don't introduce new features or fix bugs, triggers a patch release. -
docs: Updates or changes to documentation. Does not trigger a release. -
style: Changes that do not affect the code's functionality. Does not trigger a release. -
refactor: Modifications to the code that do not fix bugs or add features but improve code structure or readability. Does not trigger a release. -
test: Changes related to adding or modifying tests. Does not trigger a release. -
chore: Routine tasks that don't affect the application, such as updating build processes, package manager configs, etc. Does not trigger a release. -
build: Changes that affect the build system or external dependencies. May trigger a release. -
ci: Modifications to continuous integration configuration files and scripts. Does not trigger a release. -
revert: Reverts a previous commit. It could result in a patch, minor, or major release.
-
- MR contains database changes including schema/background migrations: not applicable.
- Change contains a breaking change - apply the breaking change label: not applicable.
- Change is considered high risk - apply the label high-risk-change
- I created or linked to an existing issue for every added or updated
TODO,BUG,FIXMEorOPTIMIZEprefixed comment (there are none). - Changes cannot be rolled back: not applicable, a revert restores the previous behavior.
Documentation/resources
Reviewer checklist
- Ensure the commit and MR tittle are still accurate.
- If the change contains a breaking change, verify the breaking change label.
- If the change is considered high risk, verify the label high-risk-change
- Identify if the change can be rolled back safely. (note: all other reasons for not being able to rollback will be sufficiently captured by major version changes).