perf: cut redundant work out of job setup and teardown (#1218)

Implement speedups to job start and shutdown.

- Create the job container while services are still becoming healthy, and poll their health at a flat one second instead of a 2s to 32s doubling backoff
- Pull each service image once instead of twice, and fetch a warm action cache once instead of twice
- Report the job result before reclaiming its volumes, and reap volumes stranded by a runner that died mid-job

| Step | Scenario | Before | After |
| --- | --- | --- | --- |
| Complete job | Large workspace volume | 4.2s | 0.4s |
| Set up job | One service, 2s health interval | 7.08s | 3.26s |
| Set up job | Two cached actions from github.com | 1.81s | 1.34s |
| Set up job | Two cached actions from gitea.com | 2.42s | 1.95s |
| Set up job | Two actions, cold action cache | 6.62s | unchanged |
| Set up job | Minimal job, no services or actions | 0.62s | unchanged |

Assisted-by: Claude Code:Opus 5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1218
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-09-10 19:00:06 +00:00
committed by bircni
parent 498282caaa
commit 54978255f5
11 changed files with 591 additions and 254 deletions
+115
View File
@@ -5,10 +5,18 @@ package run
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/runner"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
@@ -73,6 +81,113 @@ func TestRunnerRunningCountAndNullLogger(t *testing.T) {
require.NotNil(t, logger.Out)
}
func TestRunnerReclaimsVolumesAfterReporting(t *testing.T) {
for _, mode := range []string{"deferred", "post-task script"} {
t.Run(mode, func(t *testing.T) {
cfg := &config.Config{
Cache: config.Cache{Enabled: new(false)},
Runner: config.Runner{Timeout: time.Minute, LogReportInterval: time.Minute, StateReportInterval: time.Minute},
Container: config.Container{Network: "host", DockerHost: "-"},
}
if mode == "post-task script" {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX script")
}
cfg.Runner.PostTaskScript = filepath.Join(t.TempDir(), "post-task.sh")
require.NoError(t, os.WriteFile(cfg.Runner.PostTaskScript, []byte("#!/bin/sh\n: > \"$0.done\"\n"), 0o700))
}
cli := clientmocks.NewClient(t)
cli.AddressValue = "https://gitea.example/"
r := NewRunner(cfg, &config.Registration{UUID: "runner-1", Labels: []string{"ubuntu:docker://node:20"}}, cli)
var reported atomic.Bool
var removed atomic.Int64
cli.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
assert.False(t, reported.Load())
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
})
cli.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, req.Msg.State.Result)
assert.Equal(t, int64(1), r.RunningCount())
if mode == "post-task script" {
assert.FileExists(t, cfg.Runner.PostTaskScript+".done")
}
reported.Store(true)
}
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
})
var volumesCreated atomic.Bool
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("API-Version", "1.47")
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
if response, ok := map[string]string{
"/_ping": "OK",
"/info": `{"Architecture":"amd64","OSType":"linux"}`,
"/containers/json": "[]",
"/networks": "[]",
"/volumes": `{"Volumes":[]}`,
"/containers/create": `{"Id":"job-id"}`,
"/containers/job-id/json": `{"Id":"job-id","Config":{},"State":{"Status":"running"}}`,
}[path]; ok {
_, _ = io.WriteString(writer, response)
return
}
switch {
case strings.HasPrefix(path, "/images/"):
_, _ = io.WriteString(writer, `{"Id":"image-id","Config":{},"Os":"linux","Architecture":"amd64"}`)
case path == "/volumes/create":
volumesCreated.Store(true)
_, _ = io.WriteString(writer, "{}")
case strings.HasSuffix(path, "/exec"):
_, _ = io.WriteString(writer, `{"Id":"exec-id"}`)
case strings.HasPrefix(path, "/exec/"):
_, _ = io.WriteString(writer, `{"Running":false,"ExitCode":0}`)
case request.Method == http.MethodDelete || strings.HasSuffix(path, "/start") || strings.HasSuffix(path, "/kill") || strings.HasSuffix(path, "/archive"):
if request.Method == http.MethodDelete && strings.HasPrefix(path, "/volumes/") && volumesCreated.Load() {
assert.Equal(t, mode != "post-task script", reported.Load())
if mode == "post-task script" {
assert.NoFileExists(t, cfg.Runner.PostTaskScript+".done")
}
assert.Equal(t, int64(1), r.RunningCount())
removed.Add(1)
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
http.NotFound(writer, request)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
require.NoError(t, r.Run(t.Context(), &runnerv1.Task{
Context: &structpb.Struct{},
WorkflowPayload: []byte("jobs:\n job:\n runs-on: ubuntu\n steps:\n - run: exit 0\n if: false\n"),
}))
assert.True(t, reported.Load())
assert.Equal(t, int64(2), removed.Load())
assert.Zero(t, r.RunningCount())
})
}
}
func TestCleanupJobVolumesJoinsErrorsAfterCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := cleanupJobVolumes(ctx, []common.Executor{
func(ctx context.Context) error {
require.NoError(t, ctx.Err())
deadline, ok := ctx.Deadline()
assert.True(t, ok)
assert.InDelta(t, time.Minute.Seconds(), time.Until(deadline).Seconds(), 1)
return io.EOF
},
func(context.Context) error { return io.ErrClosedPipe },
})
require.ErrorIs(t, err, io.EOF)
require.ErrorIs(t, err, io.ErrClosedPipe)
}
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cacheEnabled := false
cfg := &config.Config{}