Repository navigation
perf(queue): reuse workers and consolidate task results - #180
Merged
Merged
Conversation
Preserve timeout and callback independence while reducing per-task scheduling overhead. Add lifecycle regressions and end-to-end benchmarks.
This was referenced Oct 31, 2026
- Return errors and panics through one buffered result channel - Preserve late completion and shutdown behavior without changing retry timers - Add result lifecycle regressions and focused allocation benchmarks
This was referenced Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary and scope
Short tasks previously created a Request goroutine, a work goroutine, and a handler goroutine for every job. Request was already serialized because the dispatcher waited for each result. Call Request directly from one persistent dispatcher and reuse work goroutines to remove per-task startup/teardown and routineGroup accounting.
Specific changes:
consumeworkers instead of creating a new work goroutine per task. Task delivery still uses a channel; this does not eliminate all channel synchronization.Per-task goroutine creation is 3 → 1 without afterFn and 3 → 2 with afterFn, excluding persistent workers created at initialization or growth. These counts are derived from code paths, not runtime-profile samples.
This PR changes
queue.go,options.go,scheduling_test.go,queue_performance_test.go,handle_result_test.go, andhandle_performance_test.go. It makes no Ring changes. Ring PR #179 has already merged into master; compatibility with that master version is tested below. Both performance experiments used the original Ring implementation to isolate Queue changes. Split from #178; no issue-closing keyword is intended.Flow
flowchart TD Backend[Unchanged backend Request] --> Dispatcher[queue.go: persistent start dispatcher] Dispatcher --> Worker[queue.go: reusable consume workers] Worker --> Handler[Independent handler: timeout and retries preserved] Handler --> Result[One buffered result: error or panic] Result --> Work[queue.go: work completion and metrics] Work --> Wake[queue.go: schedule wakeup] Wake --> Dispatcher Work --> Callback[queue.go: independent tracked afterFn] Resize[UpdateWorkerCount] --> Wake style Dispatcher fill:#dff0d8,stroke:#3c763d style Worker fill:#dff0d8,stroke:#3c763d style Work fill:#dff0d8,stroke:#3c763d style Wake fill:#dff0d8,stroke:#3c763d style Callback fill:#dff0d8,stroke:#3c763dStage 2: result-channel consolidation (2026-11-01)
Incremental comparison: worker-reuse baseline
4d0f9d945db6594c8ea1c7241be2f8fdffc55357versus the channel-only implementation now committed as43b2074d24e7bdf138a5870db818c0f970871d0d. The measuredqueue.goand both benchmark sources are byte-identical to the committed versions (SHA256 recorded below). The experimental benchmark filename washandle_experiment_benchmark_test.go; it is committed unchanged ashandle_performance_test.go.Previously every
handleinvocation allocated two buffered channels even though a handler reports either an error/success or a panic, never both. One result struct carrieserrandpanicValuethrough a single capacity-1 channel. The measured saving is two allocations and approximately 128 bytes per task. The normal result select and the shutdown wait both rethrow panic results and return error results as before. Results are not pooled across tasks: a timed-out handler may still be running and must retain its own destination. Handler goroutines, context timeouts, retry counts, andtime.Afterretry delays remain unchanged. Timer reuse is not part of this PR.End-to-end results
Values are medians of six samples. Time reduction is
1 - after/before.Direct handle cost
This isolates
handle, without enqueue/dispatcher overhead. Each invocation resets attempts and RetryCount, fails the specified number of times, then succeeds. Retry delay is deliberately 1ns and timeout is one minute; this is a timer/handler microbenchmark, not production backoff latency.No-retry direct handle time decreases 14.0%; this component result is separate from end-to-end throughput.
Method, interpretation, and reproduction
Apple M4 Pro, darwin/arm64, Go 1.26.10, CPU setting 12, 500ms per case, two samples per round over three rounds. Runs were sequential without race instrumentation. The original experiment interleaved baseline, channel-only, and a separate timer-reuse candidate; only baseline and channel-only results are included here. Round orders were baseline/channel/timer, timer/baseline/channel, and channel/timer/baseline. The timer candidate is excluded from this commit.
End-to-end tests use a single producer, empty handlers, and batches up to 1,024; final Release is timed and successful task counts are checked. This measures amortized task cost, not p99 or individual queue latency. Multiworker/callback timing ranges overlap, so no statistical-significance or production speedup claim is made. Allocation reductions are more consistent than timing. Different dates produced different baseline timings; do not combine stage 1 and stage 2 timing percentages. Combined Ring + final Queue performance has not been measured.
For reproduction, export
4d0f9d9and43b2074d24e7bdf138a5870db818c0f970871d0dinto separate disposable directories. Copyhandle_performance_test.gofrom the latter into the baseline;queue_performance_test.goalready exists in both. Run in each directory, interleaving versions over three rounds:Expected: benchmark success/attempt checks pass, general tasks show 11 → 9 allocations and callbacks 13 → 11; exact timing is not an assertion. Delete disposable directories afterward.
Raw samples and source hashes
Channel experiment: baseline, all six samples per case
Channel experiment: channel, all six samples per case
Stage 1: worker reuse measurements (2026-10-31)
Historical measurements on 2026-10-31 compare baseline
9721be66093a0e2889129e4751154d8388698b98with worker reuse before channel consolidation at4d0f9d945db6594c8ea1c7241be2f8fdffc55357. These replace the combined-change numbers from #178. They describe the first commit, not the final head. Do not add or multiply these timing gains with the separate November 1 experiment. Every summary value is the median of six samples.Time reduction =
1 - after/before; throughput gain =before/after - 1.Measurement method and limitations
GOMAXPROCS=12; 500ms per case, three rounds with two samples per round.Raw samples and source hashes
All relevant samples are embedded here; no report, plan, or raw-data files are added to the repository. Baseline output below is filtered to this PR’s benchmark family; other case lines are omitted, but environment, command, round markers, and hashes are preserved.
baseline — six samples per case
queue — six samples per case
Verification
Setup: check out
perf/queue-worker-reuseat43b2074d24e7bdf138a5870db818c0f970871d0d; run from the repository root with Go 1.26.10 and a working race-detector toolchain. Rungo mod downloadfor dependencies; lint tooling is pinned in go.tools.mod. No external broker, account, secret, or persistent fixture is required.go tool -modfile=go.tools.mod golangci-lint fmtgo test -race ./...go tool -modfile=go.tools.mod golangci-lint rungo test -race -run 'Test(ResultProtocol|AfterFnCanWaitForNextTask|QueueResizePauseAndDrain|ReusableWorkerOutcomes|BlockingBackendAndRepeatedStart)' -count=10 -cpu=1,4,12 -timeout=90s .git diff --checkand staged check before commitFor focused reproduction, run
go test -race -run '^TEST_NAME$' -timeout=30s .with a name below. Every scenario starts with fresh in-memory state and Passed in the full race suite and combined-branches compatibility check:TestQueueResizePauseAndDrainTestReusableWorkerOutcomesTestAfterFnCanWaitForNextTaskTestReleaseWaitsForAfterFnTestBlockingBackendAndRepeatedStartProgress signals use channels with 5-second test bounds; the callback dependency check has a 1-second bound. Cleanup releases queues and the deliberately blocked timed-out handler. No service/data cleanup is needed.
Additional result-protocol regressions (all passed with race detection):
TestResultProtocolShutdownTestResultProtocolLateCompletionTestResultProtocolRetryCancellationTestResultProtocolRepeatedRetriesRun
go test -race -run '^TestResultProtocol' -timeout=30s .for focused reproduction. The full race suite and lint passed on the final sources. An isolated integration copy of mastereaf4947(including merged Ring PR #179) with these six PR files also passedgo test -race ./.... This is correctness compatibility evidence, not a combined performance measurement.To reproduce stage 1 separately, export
9721be66093a0e2889129e4751154d8388698b98and4d0f9d9, copyqueue_performance_test.gofrom the latter into the former, and run the end-to-end benchmark families with the same 500ms/count=2/cpu=12 settings for three rounds. Stage 1 expected allocations are 17 → 11 (13 with callback); the final channel change is measured separately above.Not run: production, external-broker, multi-producer, or long-duration soak tests; no representative service workload is available in scope.
Classification and AI authorship
queue.go,options.go,scheduling_test.go,queue_performance_test.go,handle_result_test.go,handle_performance_test.go.Risk, rollback, and reviewer focus
Review Start/work/consume/start ownership, retirement, shutdown handoff, and routineGroup tracking of callbacks. Review both result receive paths in handle for error/panic equivalence. Keep the result channel buffered with capacity 1: handlers ignoring cancellation may complete after timeout. Do not reuse a result channel across tasks. Idle queues retain workers until retirement/release; callbacks may overlap. Blocking backends must still unblock Request during Shutdown.
The channel consolidation is a separate follow-up commit
43b2074d24e7bdf138a5870db818c0f970871d0dand can be reverted independently. To roll back the whole PR, revert its two commits in reverse order (or the resulting squash commit if squash-merged). No data/schema migration is involved.