CacheKeepManager.start() schedules tick() on a 60s interval with no in-flight guard:
this.timer = (this.options.setIntervalImpl ?? globalThis.setInterval)(
() => {
void this.tick().catch((error) => { ... })
},
CACHE_KEEP_TICK_MS, // 60_000
)
tick() is async and awaits each due target sequentially:
for (const target of this.targets.values()) {
if (target.cacheExpiresAt > dueAt) continue
await this.prewarm(target, now)
}
Each prewarm can take up to CACHE_KEEP_PREWARM_TIMEOUT_MS (30s), and up to CACHE_KEEP_MAX_TARGETS (32) targets can be tracked. So a single tick is bounded by roughly 32 × 30s, not by 30s — two slow targets alone are enough to exceed the 60s interval and start a second overlapping tick.
When that happens, both ticks iterate the same map and both see the same target as due, because cacheExpiresAt is only advanced after the awaited request returns. The result is duplicate prewarm requests for one cachekeep cycle. Prewarms are real billed API calls, so this is duplicated spend, and it also doubles the load on an endpoint that is already slow — which is exactly the condition that triggered it.
The slow path is not hypothetical: cachekeep tick failed {"error":"The operation timed out."} appears in my plugin log, so 30s prewarm timeouts do occur in production. Two of those in one tick is sufficient.
Fix direction: guard tick() against re-entry with an in-flight flag or promise cleared in a finally, or replace the interval with a self-scheduling loop that only re-arms after the previous pass settles. Either way stop() should ensure a stale in-flight callback cannot mutate state afterwards.
Related but independent: #150 (a thrown prewarm aborting the tick) and #149 (failing prewarms retried forever at a fixed cadence). Same file, three separate defects; I've kept them apart so they can be triaged and fixed independently.
CacheKeepManager.start()schedulestick()on a 60s interval with no in-flight guard:tick()is async and awaits each due target sequentially:Each prewarm can take up to
CACHE_KEEP_PREWARM_TIMEOUT_MS(30s), and up toCACHE_KEEP_MAX_TARGETS(32) targets can be tracked. So a single tick is bounded by roughly 32 × 30s, not by 30s — two slow targets alone are enough to exceed the 60s interval and start a second overlapping tick.When that happens, both ticks iterate the same map and both see the same target as due, because
cacheExpiresAtis only advanced after the awaited request returns. The result is duplicate prewarm requests for one cachekeep cycle. Prewarms are real billed API calls, so this is duplicated spend, and it also doubles the load on an endpoint that is already slow — which is exactly the condition that triggered it.The slow path is not hypothetical:
cachekeep tick failed {"error":"The operation timed out."}appears in my plugin log, so 30s prewarm timeouts do occur in production. Two of those in one tick is sufficient.Fix direction: guard
tick()against re-entry with an in-flight flag or promise cleared in afinally, or replace the interval with a self-scheduling loop that only re-arms after the previous pass settles. Either waystop()should ensure a stale in-flight callback cannot mutate state afterwards.Related but independent: #150 (a thrown prewarm aborting the tick) and #149 (failing prewarms retried forever at a fixed cadence). Same file, three separate defects; I've kept them apart so they can be triaged and fixed independently.