Skip to content

Commit 7b0fb2f

Browse files
committed
Add benchmarking e2e test
1 parent 86e138f commit 7b0fb2f

5 files changed

Lines changed: 248 additions & 23 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package glutton
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"net/http"
21+
22+
"github.com/agent-substrate/substrate/internal/benchmarking/boomer/dynconfig"
23+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
24+
"go.opentelemetry.io/otel"
25+
)
26+
27+
// ExerciseBenchmark runs one full boomer VU lifecycle against a live
28+
// cluster. It returns an error if there are any problems running the benchmark.
29+
func ExerciseBenchmark(ctx context.Context, apiStub ateapipb.ControlClient, httpClient *http.Client, routerURL string) error {
30+
cfg := &Config{
31+
APIStub: apiStub,
32+
HTTPClient: httpClient,
33+
RouterURL: routerURL,
34+
Atespace: "benchmark",
35+
Dyn: dynconfig.NewHolder(dynconfig.Config{}),
36+
Tracer: otel.Tracer("substrate-boomer/glutton"),
37+
}
38+
u := &gluttonUser{
39+
cfg: cfg,
40+
actorID: "contract",
41+
firstResume: true,
42+
}
43+
u.hostHeader = u.actorID + "." + cfg.Atespace + "." + actorDomain
44+
45+
if err := u.ensureAtespace(ctx); err != nil {
46+
return fmt.Errorf("EnsureAtespace: %w", err)
47+
}
48+
if err := u.create(ctx); err != nil {
49+
return fmt.Errorf("CreateActor: %w", err)
50+
}
51+
for _, phase := range []string{"cold", "warm"} {
52+
if err := u.runIteration(ctx); err != nil {
53+
return fmt.Errorf("%s iteration: %w", phase, err)
54+
}
55+
}
56+
if err := u.delete(ctx); err != nil {
57+
return fmt.Errorf("DeleteActor: %w", err)
58+
}
59+
return nil
60+
}

internal/benchmarking/boomer/glutton/lifecycle.go

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package glutton
2020
import (
2121
"bytes"
2222
"context"
23+
"errors"
2324
"fmt"
2425
"io"
2526
"log/slog"
@@ -113,16 +114,27 @@ func (r *taskRuntime) iterate() {
113114
}
114115
user := val.(*gluttonUser)
115116

116-
ctx := context.Background()
117-
if !user.resume(ctx) {
118-
return
119-
}
120-
user.ping(ctx)
121-
user.suspend(ctx)
117+
_ = user.runIteration(context.Background())
122118

123119
time.Sleep(r.dynamicWait())
124120
}
125121

122+
// runIteration is one boomer iteration for a single user: Resume, then
123+
// (on successful resume) Ping and Suspend. Ping and Suspend both run
124+
// regardless of Ping's outcome so a failed ping still puts the actor
125+
// back to Suspended. Returned error aggregates whatever went wrong;
126+
// production callers ignore it (failures land in metrics inside
127+
// tracedCall), tests use it to fail loudly on contract breakage.
128+
func (u *gluttonUser) runIteration(ctx context.Context) error {
129+
ok, err := u.resume(ctx)
130+
if !ok {
131+
return err
132+
}
133+
pingErr := u.ping(ctx)
134+
suspendErr := u.suspend(ctx)
135+
return errors.Join(pingErr, suspendErr)
136+
}
137+
126138
func (r *taskRuntime) startUser(ctx context.Context) (*gluttonUser, error) {
127139
u := &gluttonUser{
128140
cfg: r.cfg,
@@ -150,9 +162,9 @@ func (r *taskRuntime) shutdown(ctx context.Context) {
150162
r.users.Range(func(_, val any) bool {
151163
u := val.(*gluttonUser)
152164
if u.actorRunning {
153-
u.suspend(ctx)
165+
_ = u.suspend(ctx)
154166
}
155-
u.delete(ctx)
167+
_ = u.delete(ctx)
156168
bmetrics.UpdateUsers(userClass, -1)
157169
return true
158170
})
@@ -209,7 +221,11 @@ func (u *gluttonUser) create(ctx context.Context) error {
209221
})
210222
}
211223

212-
func (u *gluttonUser) resume(ctx context.Context) bool {
224+
// resume returns (ok, err): ok mirrors iterate()'s "should I keep going"
225+
// gate, err carries the underlying gRPC error so contract tests can fail
226+
// loudly. Production callers ignore err — the failure is already reported
227+
// to metrics inside tracedCall.
228+
func (u *gluttonUser) resume(ctx context.Context) (bool, error) {
213229
metricName := "ResumeActor"
214230
if u.firstResume {
215231
metricName = "ResumeActorColdStart"
@@ -222,25 +238,26 @@ func (u *gluttonUser) resume(ctx context.Context) bool {
222238
return err
223239
})
224240
if err != nil {
225-
return false
241+
return false, err
226242
}
227243
u.firstResume = false
228244
u.actorRunning = true
229-
return true
245+
return true, nil
230246
}
231247

232-
func (u *gluttonUser) suspend(ctx context.Context) {
233-
_ = u.tracedCall(ctx, "SuspendActor", func(callCtx context.Context, tr *metadata.MD) error {
248+
func (u *gluttonUser) suspend(ctx context.Context) error {
249+
err := u.tracedCall(ctx, "SuspendActor", func(callCtx context.Context, tr *metadata.MD) error {
234250
_, err := u.cfg.APIStub.SuspendActor(callCtx, &ateapipb.SuspendActorRequest{
235251
ActorRef: u.ref(),
236252
}, grpc.Trailer(tr))
237253
return err
238254
})
239255
u.actorRunning = false
256+
return err
240257
}
241258

242-
func (u *gluttonUser) delete(ctx context.Context) {
243-
_ = u.tracedCall(ctx, "DeleteActor", func(callCtx context.Context, tr *metadata.MD) error {
259+
func (u *gluttonUser) delete(ctx context.Context) error {
260+
return u.tracedCall(ctx, "DeleteActor", func(callCtx context.Context, tr *metadata.MD) error {
244261
_, err := u.cfg.APIStub.DeleteActor(callCtx, &ateapipb.DeleteActorRequest{
245262
ActorRef: u.ref(),
246263
}, grpc.Trailer(tr))
@@ -274,21 +291,24 @@ func (u *gluttonUser) tracedCall(ctx context.Context, name string, do func(conte
274291
return nil
275292
}
276293

277-
func (u *gluttonUser) ping(ctx context.Context) {
294+
// ping returns nil on a successful echo. Production callers ignore the
295+
// return value — failures are already reported to metrics — but contract
296+
// tests use it to fail loudly on router/actor breakage.
297+
func (u *gluttonUser) ping(ctx context.Context) error {
278298
ctx, span := u.cfg.Tracer.Start(ctx, "GluttonPing")
279299
defer span.End()
280300

281301
message := uuid.NewString()
282302
body, err := proto.Marshal(&gluttonpb.PingRequest{Message: message})
283303
if err != nil {
284304
bmetrics.RecordFailure("http", "GluttonPing", userClass, 0, err.Error())
285-
return
305+
return err
286306
}
287307

288308
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.cfg.RouterURL+pingPath, bytes.NewReader(body))
289309
if err != nil {
290310
bmetrics.RecordFailure("http", "GluttonPing", userClass, 0, err.Error())
291-
return
311+
return err
292312
}
293313
httpReq.Host = u.hostHeader
294314
httpReq.Header.Set("Content-Type", "application/x-protobuf")
@@ -299,37 +319,38 @@ func (u *gluttonUser) ping(ctx context.Context) {
299319
clientLatency := time.Since(start)
300320
if err != nil {
301321
bmetrics.RecordFailure("http", "GluttonPing", userClass, clientLatency, err.Error())
302-
return
322+
return err
303323
}
304324
defer resp.Body.Close()
305325

306326
respBody, readErr := io.ReadAll(resp.Body)
307327
if readErr != nil {
308328
bmetrics.RecordFailure("http", "GluttonPing", userClass, clientLatency, readErr.Error())
309-
return
329+
return readErr
310330
}
311331

312332
if resp.StatusCode >= 400 {
313333
httpErr := fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
314334
logSampledTrace(span, "GluttonPing", clientLatency, sourceClient, httpErr)
315335
bmetrics.RecordFailure("http", "GluttonPing", userClass, clientLatency, httpErr.Error())
316-
return
336+
return httpErr
317337
}
318338

319339
pong := &gluttonpb.PingResponse{}
320340
if err := proto.Unmarshal(respBody, pong); err != nil {
321341
logSampledTrace(span, "GluttonPing", clientLatency, sourceClient, err)
322342
bmetrics.RecordFailure("http", "GluttonPing", userClass, clientLatency, err.Error())
323-
return
343+
return err
324344
}
325345
if pong.Message != message {
326346
mismatch := fmt.Errorf("ping echo mismatch: sent=%q recv=%q", message, pong.Message)
327347
logSampledTrace(span, "GluttonPing", clientLatency, sourceClient, mismatch)
328348
bmetrics.RecordFailure("http", "GluttonPing", userClass, clientLatency, mismatch.Error())
329-
return
349+
return mismatch
330350
}
331351
logSampledTrace(span, "GluttonPing", clientLatency, sourceClient, nil)
332352
bmetrics.RecordSuccess("http", "GluttonPing", userClass, clientLatency, int64(len(respBody)))
353+
return nil
333354
}
334355

335356
// logSampledTrace emits a single structured line per sampled span. Operators

internal/e2e/router_client.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ func (c *RouterClient) Close() {
193193
close(c.stopCh)
194194
}
195195

196+
// BaseURL returns the local URL of the port-forwarded router (e.g.
197+
// http://127.0.0.1:34567). HTTP requests against it must set Host to the
198+
// actor's mesh DNS name (see resources.ActorDNSName) for the router to
199+
// route them.
200+
func (c *RouterClient) BaseURL() string { return c.baseURL }
201+
196202
// Get issues GET path to (atespace, actorID) through the router, setting the
197203
// actor's mesh Host so the router routes (and resumes) it. The caller must close
198204
// the body.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package benchmarking is the contract gate for the load-test workflow.
16+
// It deploys the real benchmarking/workloads manifest and drives one
17+
// boomer VU's full lifecycle via glutton.ExerciseBenchmark, so a change
18+
// to the ATE API surface or the WorkerPool/ActorTemplate manifest schema
19+
// that would silently break benchmarking fails here.
20+
package benchmarking
21+
22+
import (
23+
"context"
24+
"net/http"
25+
"path/filepath"
26+
"testing"
27+
"time"
28+
29+
"github.com/agent-substrate/substrate/internal/benchmarking/boomer/glutton"
30+
"github.com/agent-substrate/substrate/internal/e2e"
31+
"github.com/agent-substrate/substrate/pkg/api/v1alpha1"
32+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
)
34+
35+
const (
36+
workloadsNamespace = "benchmark-workloads"
37+
gluttonTemplate = "glutton"
38+
)
39+
40+
// TestGluttonBenchmarkContract helps ensure that changes will not disrupt benchmarking
41+
func TestGluttonBenchmarkContract(t *testing.T) {
42+
// Fail fast if the env the workloads manifest depends on is missing —
43+
// benchmarking/workloads/deploy.sh sources this from .ate-dev-env.sh
44+
// and substitutes it into the ActorTemplate's snapshotsConfig.location.
45+
if _, err := e2e.CheckEnv("BUCKET_NAME", "KO_DOCKER_REPO"); err != nil {
46+
t.Fatalf("CheckEnv failed: %v", err)
47+
}
48+
49+
ctx := context.Background()
50+
clients := e2e.GetClients()
51+
52+
deployWorkloads(t)
53+
waitForGluttonTemplateReady(ctx, t, clients)
54+
55+
rc, err := e2e.NewRouterClient(ctx)
56+
if err != nil {
57+
t.Fatalf("NewRouterClient: %v", err)
58+
}
59+
defer rc.Close()
60+
61+
if err := glutton.ExerciseBenchmark(ctx, clients.SubstrateAPI, &http.Client{Timeout: 30 * time.Second}, rc.BaseURL()); err != nil {
62+
t.Fatalf("glutton.ExerciseBenchmark: %v", err)
63+
}
64+
}
65+
66+
// deployWorkloads applies benchmarking/workloads/manifests/workloads.yaml.tmpl
67+
// via its own deploy.sh so any drift in the template or the script (which
68+
// orchestrator.py also invokes) is caught here. Registers a Cleanup to
69+
// tear down the workloads.
70+
func deployWorkloads(t *testing.T) {
71+
t.Helper()
72+
root, err := e2e.FindRepoRoot()
73+
if err != nil {
74+
t.Fatalf("FindRepoRoot: %v", err)
75+
}
76+
deploy := filepath.Join(root, "benchmarking/workloads/deploy.sh")
77+
78+
t.Logf("Deploying workloads via %s --deploy", deploy)
79+
e2e.RunCmd(t, deploy, "--deploy", "--worker-count", "1")
80+
81+
t.Cleanup(func() {
82+
t.Logf("Tearing down workloads via %s --delete", deploy)
83+
e2e.RunCmd(t, deploy, "--delete")
84+
})
85+
}
86+
87+
// waitForGluttonTemplateReady polls the ActorTemplate until it reaches
88+
// PhaseReady (golden snapshot has been created). Mirrors the wait in
89+
// orchestrator.py after deploy_workloads.
90+
func waitForGluttonTemplateReady(ctx context.Context, t *testing.T, clients *e2e.Clients) {
91+
t.Helper()
92+
timeout := 5 * time.Minute
93+
deadline := time.Now().Add(timeout)
94+
var lastPhase v1alpha1.PhaseType
95+
for time.Now().Before(deadline) {
96+
at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(workloadsNamespace).Get(ctx, gluttonTemplate, metav1.GetOptions{})
97+
if err == nil {
98+
lastPhase = at.Status.Phase
99+
if lastPhase == v1alpha1.PhaseReady {
100+
t.Logf("ActorTemplate %s/%s is Ready (golden=%q)", workloadsNamespace, gluttonTemplate, at.Status.GoldenSnapshot)
101+
return
102+
}
103+
if lastPhase == v1alpha1.PhaseFailed {
104+
t.Fatalf("ActorTemplate %s/%s entered PhaseFailed", workloadsNamespace, gluttonTemplate)
105+
}
106+
}
107+
time.Sleep(2 * time.Second)
108+
}
109+
t.Fatalf("timed out after %v waiting for ActorTemplate %s/%s (last phase: %s)", timeout, workloadsNamespace, gluttonTemplate, lastPhase)
110+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package benchmarking
16+
17+
import (
18+
"os"
19+
"testing"
20+
21+
"github.com/agent-substrate/substrate/internal/e2e"
22+
)
23+
24+
func run(m *testing.M) int {
25+
return e2e.RunTestMain(m)
26+
}
27+
28+
func TestMain(m *testing.M) { os.Exit(run(m)) }

0 commit comments

Comments
 (0)