Repository navigation
fix(3scale_batcher): read batched report keys while holding the lock - #1611
arshad-aziz wants to merge 1 commit into
Conversation
ReportsBatcher:get_all() listed the shared dict keys before acquiring the lock and then read them while holding it. There is one reporting timer per worker and they all flush the same service, so a second worker could list a key, lose the race for the lock, and find the key already deleted by the time it ran. storage:get() then returned nil and the comparison below raised "attempt to compare number with nil". The raised error left the lock to expire on its own, which made every request for that service wait the full 10s lock timeout in the access phase before dropping its report. It also aborted the timer thread before TimerTask could schedule the next run, so reporting stopped on that worker until the configuration was reloaded. Read the keys under the lock so deletes cannot interleave, skip entries that are gone anyway, and run timer tasks inside a pcall so that a failing run cannot stop the task from being scheduled again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The red checks here are not test failures — the CircleCI jobs never ran. All four are cancelled before any step executes, which is why they report 0s duration. The CircleCI API gives the reason: This is the gate on builds from contributors not registered on the project, so it affects any outside PR rather than this one in particular — #1609 is blocked identically, while In the meantime I ran the suites locally in the project's own CI image (
The new unit tests were also checked against the unpatched source, where they fail with the error this PR fixes: One caveat so it isn't mistaken for a clean full-suite claim: |
Fixes #1610.
Problem
ReportsBatcher:get_all()lists the shared dict keys before acquiring the lock and reads them while holding it:The policy starts one reporting
TimerTaskper worker (as documented in3scale_batcher.lua), and they all flush the sameservice_idthrough the same lock key. So two workers can both list[k1…kN], the first takes the lock and deletes them all, and the second then readsk1and getsnil.report_from_key_batched_report(key, nil)returnserr == nil, so theif not errguard does not help, andvalue > 0raises.Two separate failures follow:
lock:unlock()is never reached, so the lock sits inbatched_reports_locksuntilresty.lock's defaultexptimeof 30s. Meanwhile every request for that service waits the full 10slock_timeoutinadd()during the access phase, logsfailed to acquire the lock: timeout, and then discards its usage report. The 10s figure is a hardcoded local, so operators cannot tune around it.TimerTask.run_periodiccalls the task with nopcalland only reachesschedule_next()if it returns normally.ensure_timer_task_createdwill not recreate it either, since it guards onif not self.timer_taskand the object is still non-nil. The task is gone until a configuration reload or a restart, andbatched_reportsstops draining — which is the mechanism behind THREESCALE-9537, closed previously by making the dict size configurable rather than by addressing why it fills.Observed in production on 3.16 across several products on one gateway, with 4 workers:
followed by bursts of
reports_batcher.lua:38: add(): failed to acquire the lock: timeout. Client-visible latency exceeded 10s whileupstream_response_time_secondsstayed flat and onlytotal_response_time_secondsspiked, consistent with the stall being entirely in the access phase.(LuaJIT compiles
value > 0as0 < value, hence "compare number with nil" rather than the reverse.)Change
TimerTaskcallbacks inside apcall, logging failures, so that no future error in a task can silently stop it from being rescheduled.TimerTaskhas exactly one consumer today (this policy), so the blast radius is contained.Tests
Added regression coverage that fails on
masterand passes here:get_allreads the keys only after acquiring the lock (asserts call ordering).get_allskips a key that has gone missing and still returns the remaining reports.get_allreleases the lock when a key is missing.Verified against the unfixed source, where they reproduce the production error exactly:
Full suite on this branch, run in
quay.io/3scale/apicast-ci:openresty-1.27.1-1with a redis sidecar:Deliberately out of scope
self.storage:get_keys()takes no argument, so it returns only the first 1024 keys, and it enumerates across all services whileget_all()filters to one — so on a gateway with many products, a busy service can crowd out another's keys indefinitely. That is a real bug, but passing0changes how long the lock is held on a large dict, andget_keysis documented as able to block workers. It deserves its own PR with its own performance discussion rather than riding along with a crash fix. Noted as follow-up in #1610.🤖 Generated with Claude Code