perf: speed up org-wide sweeps and the e2e suite (#10706) · updatecli/updatecli@c94561a · GitHub
Skip to content

Commit c94561a

Browse files
loispostulaolblak
andauthored
perf: speed up org-wide sweeps and the e2e suite (#10706)
* ci: keep and summarize the traces of the e2e run The e2e step already ran with OTEL_TRACES_EXPORTER=console, but venom captures stdout into e2e/venom.log and nothing uploads it, so every run's trace was lost. The pipeline suite alone takes about 16 minutes, half of it waiting on the network, with no record of where. A local collector now writes the spans to a file. The job summary shows time by stage and resource kind, HTTP requests by host, and the slowest pipelines, and the spans are uploaded so runs can be compared. Signed-off-by: Loïs Postula <lois@postu.la> * fix(core): clean up each pull request once per execution The clean stage ran CleanAction for every pipeline, although the pipelines an autodiscovery generates for one repository share a working branch, hence one pull request. On a 2,125-pipeline sweep that meant about 2,100 cleanups of some 45 pull requests, an extrapolated 13,000 GraphQL queries, none of them logged. Actions now expose a cleanup key, and the engine cleans each key once, skipping keys published to during this execution. Signed-off-by: Loïs Postula <lois@postu.la> * perf(git): pull each branch once per execution Checkout ran before every source, condition and target, and pulled each time: 0.3-0.9s per checkout on public repositories, and most of the 162-minute pipeline stage of a 2,125-pipeline sweep, which went down to 78 minutes. The pull sets no reference, so go-git only fast-forwards the branch to the remote default branch. The working branch only changes during an execution through Updatecli's own pushes, which start from the local branch, and API commits, which pull the branch themselves. Signed-off-by: Loïs Postula <lois@postu.la> * perf(githubsearch): run each discovery query once per execution Every manifest using a githubsearch scm runs one search query, then one query per repository found to list its branches. Manifests of a compose file or a policy usually share the same search, so the same queries ran once per manifest: config loading of a 45-repository sweep went from 16.6 minutes to 42 seconds once they ran once. Query results are now kept for the execution, per credentials and query. Signed-off-by: Loïs Postula <lois@postu.la> * perf(git): only mirror branches and tags after a clone After cloning, every ref of the remote was fetched with refs/*:refs/*. On GitHub that includes the refs/pull/* kept for every pull request ever opened, with their objects, although nothing in Updatecli reads them. githubsearch has no singleBranch setting, so every repository it discovers goes through this fetch. Fetching refs/heads/* and refs/tags/* keeps the local branches and tags the fetch was meant for. Cloning with go-git, two runs each: updatecli/updatecli (10,081 pull refs): 11.4-11.7s, 133M -> 3.8-4.0s, 14M updatecli/website (3,791 pull refs): 20.2-20.6s, 55M -> 11.9-12.2s, 19M updatecli/policies (247 pull refs): 1.7-2.2s, 3.5M -> 1.1-1.2s, 1.1M Signed-off-by: Loïs Postula <lois@postu.la> * perf(githubaction): check action references without cloning Every action an autodiscovery finds gets conditions checking whether its reference is a tag, a branch or a release. The tag and branch checks cloned the action's repository: 718 MB for github/codeql-action, 262s to learn that "v3" isn't a branch. The 43 action pipelines of the e2e suite were 65% of its pipeline stage; with ls-remote the suite takes 46% less. Generated manifests now use ls-remote for the conditions and the digest sources, and for the version sources unless they use "latest", which depends on dates, or an age filter. gitbranch gains the "lsremote" option gittag has. gittag's ls-remote listing now returns the commit an annotated tag points to, like its local listing, instead of the tag object, which would have pinned actions to the wrong hash. Signed-off-by: Loïs Postula <lois@postu.la> * ci: build only the e2e binary on pull requests The Build step compiled eight platforms for every pull request, 18 of the job's 32 minutes, although the e2e tests only run the runner's binary. Pushes to main and the nightly run still build every platform. Signed-off-by: Loïs Postula <lois@postu.la> --------- Signed-off-by: Loïs Postula <lois@postu.la> Co-authored-by: Olivier Vernin <olivier@vernin.me>
1 parent 318b834 commit c94561a

22 files changed

Lines changed: 816 additions & 28 deletions

File tree

‎.github/workflows/go.yaml‎

Lines changed: 52 additions & 1 deletion

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ clean: ## Clean go test cache
1717
go clean -testcache
1818

1919
.PHONY: build
20-
build: ## Build updatecli as a "dirty snapshot" (no tag, no release, but all OS/arch combinations)
21-
goreleaser build --snapshot --clean
20+
build: ## Build updatecli as a "dirty snapshot" (no tag, no release, but all OS/arch combinations unless BUILD_FLAGS=--single-target)
21+
goreleaser build --snapshot --clean $(BUILD_FLAGS)
2222

2323
.PHONY: build.all
2424
build.all: ## Build updatecli for "release" (tag or release and all OS/arch combinations)

‎e2e/scripts/trace_summary.py‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Summarizes the OpenTelemetry spans an e2e run exported, as markdown.
3+
4+
usage: trace_summary.py SPANS_JSONL [--json OUT]
5+
6+
SPANS_JSONL is the OTLP/JSON file a collector's file exporter writes, one export per line.
7+
--json also writes the aggregates, for comparing runs.
8+
"""
9+
10+
import argparse
11+
import collections
12+
import json
13+
from urllib.parse import urlparse
14+
15+
STAGES = [
16+
"updatecli.prepare",
17+
"updatecli.load_configurations",
18+
"updatecli.init_scm",
19+
"updatecli.autodiscovery",
20+
"updatecli.run",
21+
"updatecli.push_commits",
22+
"updatecli.run_actions",
23+
"updatecli.prune_scm_branches",
24+
]
25+
26+
27+
def load(path: str) -> list[dict]:
28+
spans = []
29+
with open(path) as f:
30+
for line in f:
31+
for resource in json.loads(line)["resourceSpans"]:
32+
for scope in resource["scopeSpans"]:
33+
for span in scope["spans"]:
34+
span["attrs"] = {a["key"]: next(iter(a["value"].values()), None) for a in span.get("attributes", [])}
35+
span["seconds"] = (int(span["endTimeUnixNano"]) - int(span["startTimeUnixNano"])) / 1e9
36+
spans.append(span)
37+
return spans
38+
39+
40+
def aggregate(spans: list[dict]) -> dict:
41+
def group(selected, key):
42+
groups = collections.defaultdict(list)
43+
for span in selected:
44+
groups[key(span)].append(span["seconds"])
45+
return {
46+
name: {"count": len(v), "total_s": round(sum(v), 1), "avg_s": round(sum(v) / len(v), 3), "max_s": round(max(v), 1)}
47+
for name, v in sorted(groups.items(), key=lambda item: -sum(item[1]))
48+
}
49+
50+
def http_key(span):
51+
url = urlparse(str(span["attrs"].get("url.full", "")))
52+
return f"{span['attrs'].get('http.request.method', '?')} {url.netloc}{'/graphql' if url.path.endswith('/graphql') else ''}"
53+
54+
roots = [s for s in spans if s["name"] == "updatecli"]
55+
pipelines = [s for s in spans if s["name"] == "updatecli.pipeline"]
56+
return {
57+
"processes": len(roots),
58+
"wall_s": round(sum(s["seconds"] for s in roots), 1),
59+
"pipelines": len(pipelines),
60+
"pipeline_results": dict(collections.Counter(str(s["attrs"].get("updatecli.pipeline.result")) for s in pipelines)),
61+
"stages": group([s for s in spans if s["name"] in STAGES], lambda s: s["name"]),
62+
"resources": group(
63+
[s for s in spans if s["name"] == "updatecli.resource"],
64+
lambda s: f"{s['attrs'].get('updatecli.resource.category')}/{s['attrs'].get('updatecli.resource.kind')}",
65+
),
66+
"http": group([s for s in spans if s["name"].startswith("HTTP ")], http_key),
67+
"slowest_pipelines": [
68+
{"name": s["attrs"].get("updatecli.pipeline.name"), "result": s["attrs"].get("updatecli.pipeline.result"), "seconds": round(s["seconds"], 1)}
69+
for s in sorted(pipelines, key=lambda s: -s["seconds"])[:10]
70+
],
71+
}
72+
73+
74+
def table(title: str, groups: dict, limit: int = 15) -> str:
75+
rows = [f"### {title}", "", "| | count | total s | avg s | max s |", "|---|---:|---:|---:|---:|"]
76+
rows += [f"| `{name}` | {g['count']} | {g['total_s']} | {g['avg_s']} | {g['max_s']} |" for name, g in list(groups.items())[:limit]]
77+
return "\n".join(rows) + "\n"
78+
79+
80+
def markdown(summary: dict) -> str:
81+
out = [
82+
"## Updatecli e2e trace summary",
83+
"",
84+
f"{summary['processes']} updatecli process(es), {summary['wall_s']}s in total, {summary['pipelines']} pipelines "
85+
f"({', '.join(f'{k}: {v}' for k, v in sorted(summary['pipeline_results'].items()))}).",
86+
"",
87+
"Resources of a pipeline run concurrently, so resource totals can exceed the wall time.",
88+
"",
89+
table("Stages", summary["stages"]),
90+
table("Resources by category/kind", summary["resources"]),
91+
table("HTTP requests by method and host", summary["http"]),
92+
"### Slowest pipelines",
93+
"",
94+
"| pipeline | result | s |",
95+
"|---|---|---:|",
96+
]
97+
out += [f"| {p['name']} | {p['result']} | {p['seconds']} |" for p in summary["slowest_pipelines"]]
98+
return "\n".join(out) + "\n"
99+
100+
101+
def main() -> None:
102+
parser = argparse.ArgumentParser(description="Summarizes the OpenTelemetry spans an e2e run exported.")
103+
parser.add_argument("spans")
104+
parser.add_argument("--json")
105+
args = parser.parse_args()
106+
107+
summary = aggregate(load(args.spans))
108+
if args.json:
109+
with open(args.json, "w") as f:
110+
json.dump(summary, f, indent=2)
111+
print(markdown(summary), end="")
112+
113+
114+
if __name__ == "__main__":
115+
main()

‎pkg/core/engine/actions.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,23 @@ func (e *Engine) runActions(ctx context.Context) error {
2929
}
3030
}
3131

32+
logrus.Infof("Cleaning up actions published by previous executions")
33+
34+
// Pipelines sharing a working branch share one pull request, so it's cleaned once,
35+
// and never when any of them published to it during this execution.
36+
handled := map[string]bool{}
37+
for _, pipeline := range e.Pipelines {
38+
for _, a := range pipeline.Actions {
39+
if key := a.CleanupKey(); a.Published && key != "" {
40+
handled[key] = true
41+
}
42+
}
43+
}
44+
3245
for id := range e.Pipelines {
3346
pipeline := e.Pipelines[id]
3447
if len(pipeline.Actions) > 0 {
35-
if err := pipeline.RunCleanActions(ctx); err != nil {
48+
if err := pipeline.RunCleanActions(ctx, handled); err != nil {
3649
errs = append(errs, "cleaning: "+err.Error())
3750
pipeline.Report.Result = result.FAILURE
3851
logrus.Errorf("cleaning action stage:\t%q", err.Error())

‎pkg/core/pipeline/action/main.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ type Action struct {
8989
Published bool
9090
}
9191

92+
// CleanupKey identifies the remote object the action cleans up, such as a pull request,
93+
// shared by every pipeline using the same working branch. Empty when the action has no scm.
94+
func (a Action) CleanupKey() string {
95+
if a.Scm == nil || a.Scm.Handler == nil {
96+
return ""
97+
}
98+
_, workingBranch, targetBranch := a.Scm.Handler.GetBranches()
99+
return fmt.Sprintf("%s|%s|%s|%s|%v", a.Config.Kind, a.Scm.Handler.GetURL(), workingBranch, targetBranch, a.Config.Spec)
100+
}
101+
92102
// Validate ensures that an action configuration has required parameters.
93103
func (c *Config) Validate() (err error) {
94104
missingParameters := []string{}

‎pkg/core/pipeline/actions.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ func (p *Pipeline) RunActions(ctx context.Context) error {
242242
}
243243

244244
// RunCleanActions executes clean up operation which depends on the action plugin.
245-
func (p *Pipeline) RunCleanActions(ctx context.Context) error {
245+
// handled holds the cleanup keys already cleaned or published during this execution.
246+
func (p *Pipeline) RunCleanActions(ctx context.Context, handled map[string]bool) error {
246247
var errs []string
247248

248249
// Early return
@@ -268,6 +269,13 @@ func (p *Pipeline) RunCleanActions(ctx context.Context) error {
268269
continue
269270
}
270271

272+
if key := action.CleanupKey(); key != "" {
273+
if handled[key] {
274+
continue
275+
}
276+
handled[key] = true
277+
}
278+
271279
// At least we try to clean existing pullrequest
272280
err := action.Handler.CleanAction(ctx, &action.Report)
273281
if err != nil {

‎pkg/core/pipeline/actions_test.go‎

Lines changed: 41 additions & 1 deletion

0 commit comments

Comments
 (0)