Skip to content

perf(package-rules): avoid re-cloning invariant packageRules array on every rule match in applyPackageRules #44459

Description

@RahulGautamSingh

Discussed in #44444

Originally posted by eyalzek July 9, 2026

How are you running Renovate?

Self-hosted Renovate CLI (official renovate/renovate image) on Kubernetes.

Which platform are you running Renovate on?

GitHub.com

Which version of Renovate are you using?

43.251.3 (behavior observed consistently across 43.200.0 → 43.255.x)

Please tell us more about your question or problem

Summary: on a large monorepo, ~88% of the whole Renovate run's CPU goes into applyPackageRules — dominated by klona deep-clones via mergeChildConfig — executed once per matching rule per dependency instance. Because vulnerabilityAlerts compiles every open Dependabot alert into force package rules (with full advisory markdown in prBodyNotes) that join the per-dependency config, the effective cost scales roughly O(open alerts × package rules × dependency instances). On our repo this makes the lookup phase run for hours, and — worse than just being slow — the sustained synchronous clone/match churn starves the event loop for 60–90s at a stretch, so in-flight sockets get closed by peers (socket hang up on GraphQL POSTs, TLS handshakes timing out). A single such failure on a github datasource escalates to ExternalHostError, which aborts the whole repository run before any PRs are created.

Repo shape: monorepo with ~400 package files (~180 go.mod, ~200 package.json, plus Dockerfiles/workflows), i.e. tens of thousands of dependency instances (the same transitive Go dep appears in ~180 files and is fully re-processed per file). Several thousand open Dependabot alerts (large majority non-actionable transitive go-module advisories).

CPU profile (3-minute capture via inspector during the lookup phase, totals of self-time):

self time share function location
~120s 66% klona klona/json via clone()
~35s 19% matchesRule util/package-rules
~4s 2.3% applyPackageRules util/package-rules
~8s 4.6% (garbage collector)

Every hot klona call site has the same stack: applyPackageRules → mergeChildConfig → clone (klona).

Observed impact measurements:

  • Disabling vulnerabilityAlerts alone made dependency processing ~20× faster on this repo (same run otherwise: caches, node size, config) — consistent with the alert-derived rules being the biggest object in every clone.
  • With alerts enabled, throughput was ~1 dependency-instance/second of pure local CPU (single core pinned, network mostly idle) — even for deps that end up enabled: false and need no lookups at all.
  • It's self-amplifying: when runs abort, no update/security PRs merge, so open alerts accumulate, so the per-dep cost grows, so the next run is slower. Our alert count roughly 2.5×'d in the weeks after runs started failing.
  • Raising the heap (--max-old-space-size) removed GC pressure (we measured mu = 0.006 when running against the default 4GB cap) but the event-loop stalls persist — it's compute, not memory.

Why it's fatal and not just slow: the multi-minute event-loop stalls kill unrelated in-flight requests; a failed POST /graphql (not retried by got) from github-tags/github-releases becomes ExternalHostError, and for onboarded repos lib/workers/repository/process/fetch.ts propagates that as a repo abort (external-host-error) with no config opt-out. So past a certain repo size × alert count, runs abort ~100% of the time.

Suggested directions (happy to help with a PR if maintainers agree on an approach):

  1. Memoize/deduplicate rule application per identical input — the same dependency (name, datasource, versioning, essentially identical file-level config) is fully re-evaluated for each of the ~180 package files that contain it. A cache keyed on the inputs of applyPackageRules would cut our instance count ~two orders of magnitude.
  2. Avoid deep-cloning the full config per matching rulemergeChildConfig(config, rule) clones the entire accumulated config (which itself contains the complete packageRules array, including the alert-derived rules) once per matching rule. Options: strip packageRules (and other large, invariant keys) before the per-rule merge loop and reattach after; or accumulate matched rules and merge once.
  3. Keep advisory bodies out of the per-dependency config path — the alert-derived force rules embed full advisory markdown (prBodyNotes) that is only needed at PR-creation time; storing bodies out-of-band and referencing them by id would shrink the cloned object dramatically for repos with many alerts.

I can share the full .cpuprofile and put together a synthetic public reproduction (N go.mod files sharing M deps + a large generated packageRules array) if that helps triage.

Logs (if relevant)

Representative sequence during a stall (log output drops from ~5 lines/s to ~1 line/20-30s, then all failures land in one burst):

DEBUG: Dependency: go.opentelemetry.io/otel/metric, is disabled   (per-instance evaluations 20-30s apart)
...
DEBUG: POST https://api.github.com/graphql = (code=ECONNRESET, statusCode=-1 retryCount=0, duration=128272)
DEBUG: Shrinking GitHub GraphQL page size after error
WARN:  Error obtaining docker token: Timeout awaiting 'request' for 60000ms
WARN:  Host error (github-tags): Client network socket disconnected before secure TLS connection was established
INFO:  Repository finished  "result": "external-host-error"
```</div>

Metadata

Metadata

Assignees

No one assigned

    Labels

    core:package-rulesRelating to package-rules e.g. matchers

    Type

    Fields

    Priority

    Medium

    Datasource

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions