Skip to content

[BUG] cachekeep ticks can overlap and issue duplicate paid prewarms for the same target #151

Description

@iceteaSA

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions