fix(platform): PAYMENTS-11727 Deliver metrics pushed from inside a Resque job - #42
Closed
WillemHoman wants to merge 11 commits into
Closed
fix(platform): PAYMENTS-11727 Deliver metrics pushed from inside a Resque job#42WillemHoman wants to merge 11 commits into
WillemHoman wants to merge 11 commits into
Conversation
added 8 commits
August 6, 2026 18:10
… overhead across Resque forks
…ush is installed at boot
This was referenced Aug 6, 2026
added 3 commits
August 6, 2026 20:59
…easuring changes by hand
…its for an in-flight send
Contributor
Author
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.
Jira: PAYMENTS-11727
What? Why?
Metrics pushed from inside a Resque job were being dropped. Bigpay released
0.8.3withPROMETHEUS_RESQUE_PER_JOB_METRICS_ENABLED=1andruby_webhooks_published_counterfell to about 8% of normal on everyworker pod, recovering exactly on rollback. No webhooks were lost, no jobs failed and the queue never backed up. Only
the recording broke.
Two independent causes, both in how the client survives Resque's fork.
The child inherited the parent's outbound queue. The client is a singleton and its queue is ordinary process
memory, so
forkcopies it.JobMetrics.record_queue_latencyenqueues in the parent on the line immediately beforesuper, which is the fork, so every child inherited that message and had to re-send it, and anything else pending,before reaching its own. Fixed with a
Resque.after_forkhook that gives the child a clean client. Nothing is lost:the parent still holds the originals and sends them on its own schedule.
Pushing only queues, and
exit!does not wait. Delivery happens on a background thread that wakes everyclient_thread_sleepseconds. A Resque child ends withexit!, which runs no at_exit handlers and kills threadsoutright, so an observation pushed near the end of a job was destroyed with the child. This predates
0.8.3and wascosting observations quietly. Fixed by draining on the calling thread before the job returns, via a prepend on
Resque::Worker#perform, which is the in-child boundary. Measured cost is 0.90ms per job that pushes something andnothing at all for jobs that do not. Disable with
PROMETHEUS_RESQUE_CHILD_FLUSH_ENABLED=0.The two are complementary and ordered. Without the reset, the flush would synchronously send the parent's whole
backlog, which is the 480ms per job regression that got bigcommerce/bigpay#10597 reverted.
An empty queue is not an empty wire. Found by the bench below, on its first run. The flush originally returned early
when nothing was queued, and the queue reads as empty the instant the background thread pops the last message, well
before that message reaches the collector.
exit!then destroyed the request. Roughly 0.1% to 0.3% of in-child pushes,silently, because the process that would have logged it was already gone. Confirmed at the socket layer: the listener
accepted every connection but read fewer complete requests, and the shortfall matched its count of connections opened
and then closed with no request line. Fixed by serialising both drain paths on a delivery mutex, so a flush cannot
return while a send is in flight. That also closes a hang where both threads saw one queued message, both called
pop,and the loser blocked forever.
A job should not wait long on the metrics pipeline. Making the child wait for delivery means it now waits for a
collector that is not answering, and
0.8.3has no such exposure because nothing in a job's code path ever touched thenetwork. Delivery is therefore bounded by
PROMETHEUS_CLIENT_FLUSH_TIMEOUT, 20ms by default, covering the wait for thelock as well as the requests. Past it the observations are abandoned, because availability of the work beats
completeness of its metrics.
Also in here:
Net::HTTPdefaults all three to 60 seconds, whichis survivable on a background thread and not survivable inline in a job.
exit!destroys it.exit!runs nohandlers and empties no buffers, so a line written to a buffered STDOUT in a child never reaches the log. This
applied to the warnings the client already emitted, which have been unreliable in children all along.
bin/resque_fork_bench, a manual tool that forks real children and reports both what arrived and what it cost, witha
--smoke-testsweep. Every number below came from it.resqueandsinatra >= 4.0as dev dependencies. Resque pulls sinatra with a loose constraint and, with noGemfile.lockin this repo, a cold CI resolve was free to pick a sinatra that capsrack < 3against the gemspec'srack >= 3.0. This is the dependency work deferred from PAYMENTS-11727 Resque latency metrics #31.rather than inferred.
How was it tested?
spec/bigcommerce/prometheus/client_spec.rb— unit coverage for#flush!and#reset_after_fork!: delivery on thecalling thread, never raising into the caller, waiting for an in-flight send, giving up on the deadline, bounded
timeouts on both paths, and clearing of the queue, worker thread, both mutexes and socket state. One example binds a
real socket that accepts and never answers, because a timeout cannot be asserted against a stub.
spec/integration/resque_fork_delivery_spec.rb— black box, forking real Resque children against a real listener.Asserts two properties and deliberately mentions neither queues nor forks, because past changes have broken each from
opposite directions:
cancels out.
Excluded from the default run and given its own job,
ruby-3_4-rspec_fork_integration. Therubyexecutor alreadyprovides redis, so no service needed adding.
None of it is vacuous, and each was checked by removing the fix:
PROMETHEUS_RESQUE_CHILD_FLUSH_ENABLED=0< 1.0Cost of serialising delivery, A/B on the same machine, medians of five runs at one push and three at five:
Roughly half a millisecond on the shape bigpay runs. That is the calling thread paying for a request it previously
handed to a thread that was about to be killed.
Cost when the collector is not healthy, 50 jobs pushing one metric each:
The last row is what the per-request timeouts alone would give. A dead collector costs nothing, since
ECONNREFUSEDisimmediate. Only a saturated one is expensive, and that is what the bound exists for.
Two draft PRs run these same specs against the two regressions they exist to catch, one property failing on each:
#43 on
0.8.3as released, where completeness fails at 0 of 100, and #44 on the PAYMENTS-11567 approach, wherecompleteness passes and overhead fails. Both are expected to be red and are not for merge.
A connection-reuse optimisation was also written and measured, then dropped: the difference was inside run-to-run noise
and did not justify the complexity.