Author SHA1 Message Date
Nikola Jokic 598ce5f7b7 add golangci-lint to go toold 2026-04-24 10:49:28 +02:00
Nikola Jokic 39059cabba bump golangci-lint 2026-04-24 10:46:27 +02:00
Nikola Jokic c062a1d55d Bump Go and go mods 2026-04-24 10:40:49 +02:00
dependabot[bot] 02ad99e7fe Bump the actions group with 2 updates (#81)
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

Updates `golangci/golangci-lint-action` from 8.0.0 to 9.2.0
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/4afd733a84b1f43292c63897423277bb7f4313a9...1e7e51e771db61008b38414a730f564565cf7c20)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 10:37:38 +02:00
Francesco Renzi 22bae1217e Restore job acquisition flow (#90) 2026-04-14 09:27:42 +01:00
dependabot[bot] a0708d5ea7 Bump the gomod group with 2 updates (#82)
Bumps the gomod group with 2 updates: [github.com/spf13/cobra](https://github.com/spf13/cobra) and [golang.org/x/net](https://github.com/golang/net).


Updates `github.com/spf13/cobra` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2)

Updates `golang.org/x/net` from 0.48.0 to 0.52.0
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.52.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 09:30:55 +01:00
Nikola Jokic 0325c63c37 Add dependabot (#80) 2026-03-28 14:37:00 +01:00
dependabot[bot] 48afc028b8 Bump google.golang.org/grpc from 1.72.2 to 1.79.3 (#76)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.72.2 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.72.2...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 13:07:37 +01:00
Nikola Jokic feb84c6d04 Allow users to specify their own client implementation used by the library (#73)
* Allow users to specify their own client implementation used by the library

* fix typos

* add tests
2026-02-18 23:46:57 +01:00
Nikola JokicandCopilot 0488917cf3 Use *slog.Logger instead of slog.Logger in option (#74)
* Use *slog.Logger instead of slog.Logger in option

* Update common_client.go

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
2026-02-18 19:15:23 +01:00
Nikola Jokic f9f801fb38 Add metrics hooks to the listener (#72)
* Add metrics hook

* extend current tests to cover recorder

* address nil passed in option, fix typo

* mocks
2026-02-17 10:12:57 +01:00
Nikola Jokic ebedf0bbbf Lock main client from inside the session client when session requests are made (#71)
* Lock outer client from the main client from the session client when used

* Add race to test
2026-02-16 12:58:50 +01:00
Steve-Glass efa922e8dd Merge pull request #70 from actions/update-readme-with-multi-label
Clarify scale set registration process
2026-02-05 07:50:38 -05:00
Steve-Glass ff25a89ba7 Clarify scale set registration process
Added clarification that multiple labels can be assigned per scale set.
2026-02-05 07:42:45 -05:00
Nikola Jokic 2f9b84ee5a Fix status in readme (#69) 2026-02-05 11:48:08 +00:00
Francesco Renzi 63a0a32683 It's alive! (#68)
Updated README to reflect the change from Private Preview to Public Preview status.
2026-02-05 09:52:53 +01:00
e4a017ce06 Initial commit for open source release 🚀
Co-authored-by: Francesco Renzi <[email protected]>
Co-authored-by: Nikola Jokic <[email protected]>
2026-02-03 16:41:15 +01:00
15 changed files with 2041 additions and 171 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "gomod" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
groups:
gomod:
patterns:
- "*"
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
+7 -10
View File
@@ -19,7 +19,7 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
@@ -32,7 +32,7 @@ jobs:
mocks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
@@ -45,24 +45,21 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9
with:
only-new-issues: true
version: v2.5.0
- name: "Lint"
run: go tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint run ./...
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: true
- name: Run tests
run: go test ./...
run: go test ./... -race
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 GitHub
Copyright GitHub, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -3
View File
@@ -1,6 +1,6 @@
# GitHub Actions Runner Scale Set Client (Private Preview)
# GitHub Actions Runner Scale Set Client (Public Preview)
> Status: **Private Preview** – While the API is stable, interfaces and examples in this repository may change.
> Status: **Public Preview** – While the API is stable, interfaces and examples in this repository may change.
This repository provides a standalone Go client for the GitHub Actions **Runner Scale Set** APIs. It is extracted from the `actions-runner-controller` project so that platform teams, integrators, and infrastructure providers can build **their own custom autoscaling solutions** for GitHub Actions runners.
@@ -12,7 +12,7 @@ You do *not* need to adopt the full controller (and Kubernetes) to take advantag
A runner scale set is a group of self-hosted runners that autoscales based on workflow demand. Here's how it works:
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Multiple labels can be assigned per scale set. Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
2. **Polling**: Your scale set client continuously polls the API, reporting its maximum capacity (how many runners it can produce).
3. **Job matching**: GitHub matches jobs to your scale set based on the label and runner group policies, just like regular self-hosted runners.
4. **Scaling signal**: The API responds with how many runners your scale set needs online (`statistics.TotalAssignedJobs`).
+13 -6
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/url"
@@ -127,9 +126,9 @@ type ProxyFunc func(req *http.Request) (*url.URL, error)
type SystemInfo struct {
// System is the name of the scale set implementation
System string `json:"system"`
// Version is the version of the controller
// Version is the version of the client
Version string `json:"version"`
// CommitSHA is the git commit SHA of the controller
// CommitSHA is the git commit SHA of the client
CommitSHA string `json:"commit_sha"`
// ScaleSetID is the ID of the scale set
ScaleSetID int `json:"scale_set_id"`
@@ -223,6 +222,7 @@ type userAgent struct {
SystemInfo
BuildVersion string `json:"build_version"`
BuildCommitSHA string `json:"build_commit_sha"`
Kind string `json:"kind"`
}
func (c *Client) newGitHubAPIRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
@@ -510,6 +510,14 @@ func parseRunnerScaleSetMessageResponse(respBody io.Reader) (*RunnerScaleSetMess
}
switch messageType.MessageType {
case MessageTypeJobAvailable:
var jobAvailable JobAvailable
if err := json.Unmarshal(msg, &jobAvailable); err != nil {
return nil, fmt.Errorf("failed to decode job available: %w", err)
}
message.JobAvailableMessages = append(message.JobAvailableMessages, &jobAvailable)
case MessageTypeJobAssigned:
var jobAssigned JobAssigned
if err := json.Unmarshal(msg, &jobAssigned); err != nil {
@@ -546,7 +554,6 @@ func parseRunnerScaleSetMessageResponse(respBody io.Reader) (*RunnerScaleSetMess
// It exposes client options that could be overwritten, providing ability to specify different retry policies or TLS settings, proxy, etc.
func (c *Client) MessageSessionClient(ctx context.Context, runnerScaleSetID int, owner string, options ...HTTPOption) (*MessageSessionClient, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Copy original options
httpClientOption := c.httpClientOption
@@ -567,6 +574,8 @@ func (c *Client) MessageSessionClient(ctx context.Context, runnerScaleSetID int,
scaleSetID: runnerScaleSetID,
session: nil,
}
// Unlock the client to allow createMessageSession to call public methods that require locking
c.mu.Unlock()
if err := client.createMessageSession(ctx); err != nil {
return nil, fmt.Errorf("failed to create message session: %w", err)
@@ -836,8 +845,6 @@ func (c *Client) getActionsServiceAdminConnection(ctx context.Context, rt *regis
return nil, fmt.Errorf("failed to get actions service admin connection: %w", err)
}
slog.Info("got connection", *adminConnection.ActionsServiceURL, c.userAgent)
return adminConnection, nil
}
+56 -13
View File
@@ -75,12 +75,20 @@ func sendRequest(c *http.Client, req *http.Request) (*http.Response, error) {
}
type httpClientOption struct {
logger *slog.Logger
retryMax int
retryWaitMax time.Duration
logger *slog.Logger
// Options for built-in retryable HTTP client.
// Ignored if a custom retryable HTTP client is provided via WithRetryableHTTPClint.
retryMax int
retryWaitMax time.Duration
// fields added to the transport if specified
rootCAs *x509.CertPool
tlsInsecureSkipVerify bool
proxyFunc ProxyFunc
timeout time.Duration
retryableHTTPClient *retryablehttp.Client
}
func (o *httpClientOption) defaults() {
@@ -93,14 +101,28 @@ func (o *httpClientOption) defaults() {
if o.retryWaitMax == 0 {
o.retryWaitMax = 30 * time.Second
}
if o.timeout == 0 {
o.timeout = 5 * time.Minute
}
}
func (o *httpClientOption) newRetryableHTTPClient() (*retryablehttp.Client, error) {
retryClient := retryablehttp.NewClient()
retryClient.Logger = o.logger
retryClient.RetryMax = o.retryMax
retryClient.RetryWaitMax = o.retryWaitMax
retryClient.HTTPClient.Timeout = 5 * time.Minute // timeout must be > 1m to accomodate long polling
var retryClient *retryablehttp.Client
if o.retryableHTTPClient != nil {
retryClient = o.retryableHTTPClient
} else {
retryClient = retryablehttp.NewClient()
retryClient.RetryMax = o.retryMax
retryClient.RetryWaitMax = o.retryWaitMax
}
if retryClient.HTTPClient.Timeout == 0 {
retryClient.HTTPClient.Timeout = o.timeout
}
if retryClient.Logger == nil {
retryClient.Logger = o.logger
}
transport, ok := retryClient.HTTPClient.Transport.(*http.Transport)
if !ok {
@@ -120,7 +142,9 @@ func (o *httpClientOption) newRetryableHTTPClient() (*retryablehttp.Client, erro
transport.TLSClientConfig.InsecureSkipVerify = true
}
transport.Proxy = o.proxyFunc
if o.proxyFunc != nil {
transport.Proxy = o.proxyFunc
}
retryClient.HTTPClient.Transport = transport
@@ -137,18 +161,30 @@ func (c *commonClient) setUserAgent() {
SystemInfo: c.systemInfo,
BuildVersion: buildInfo.version,
BuildCommitSHA: buildInfo.commitSHA,
Kind: "scaleset",
})
c.userAgent = string(b)
slog.Info("user agent", "useragent", c.userAgent)
}
// HTTPOption defines a functional option for configuring the Client.
type HTTPOption func(*httpClientOption)
// WithLogger sets a custom logger for the Client.
func WithLogger(logger slog.Logger) HTTPOption {
// WithRetryableHTTPClint allows users to provide a custom retryable HTTP client.
// If not set, a default client will be used with the specified retry and timeout settings.
func WithRetryableHTTPClint(client *retryablehttp.Client) HTTPOption {
return func(c *httpClientOption) {
c.logger = &logger
c.retryableHTTPClient = client
}
}
// WithLogger sets a custom logger for the Client.
// If nil is passed, a discard logger will be used.
func WithLogger(logger *slog.Logger) HTTPOption {
return func(c *httpClientOption) {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
c.logger = logger
}
}
@@ -186,3 +222,10 @@ func WithProxy(proxyFunc ProxyFunc) HTTPOption {
c.proxyFunc = proxyFunc
}
}
// WithTimeout sets a timeout for the Client.
func WithTimeout(duration time.Duration) HTTPOption {
return func(c *httpClientOption) {
c.timeout = duration
}
}
+89
View File
@@ -7,8 +7,10 @@ import (
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/actions/scaleset/internal/testserver"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/http/httpproxy"
@@ -124,6 +126,7 @@ func TestUserAgent(t *testing.T) {
SystemInfo: testSystemInfo,
BuildCommitSHA: sha,
BuildVersion: version,
Kind: "scaleset",
}
b, err := json.Marshal(wantInfo)
require.NoError(t, err, "failed to marshal expected user agent")
@@ -144,6 +147,7 @@ func TestUserAgent(t *testing.T) {
SystemInfo: userAgentInfo,
BuildCommitSHA: sha,
BuildVersion: version,
Kind: "scaleset",
}
b, err = json.Marshal(wantInfo)
require.NoError(t, err, "failed to marshal expected user agent after SetSystemInfo")
@@ -151,3 +155,88 @@ func TestUserAgent(t *testing.T) {
assert.Equal(t, want, got)
}
// TestWithRetryableHTTPClient verifies that a custom retryable HTTP client
// provided via WithRetryableHTTPClient is actually used instead of the built-in one
func TestWithRetryableHTTPClient(t *testing.T) {
t.Run("uses custom retryable client instead of built-in", func(t *testing.T) {
attemptCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attemptCount++
if attemptCount == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result": "success"}`))
}))
defer server.Close()
// Create a custom retryable HTTP client with specific retry configuration
customRetryClient := retryablehttp.NewClient()
customRetryClient.RetryMax = 3
customRetryClient.RetryWaitMax = 10 * time.Millisecond
// Create options with the custom retryable client
opts := defaultHTTPClientOption()
WithRetryableHTTPClint(customRetryClient)(&opts)
// Verify that the custom client is set in options
assert.NotNil(t, opts.retryableHTTPClient)
assert.Equal(t, customRetryClient, opts.retryableHTTPClient)
// Create the common client with custom retryable client
client := newCommonClient(testSystemInfo, opts)
// Make a request that will trigger a retry
req, err := http.NewRequest("GET", server.URL, nil)
require.NoError(t, err)
resp, err := client.do(req)
require.NoError(t, err)
// Should succeed after retry
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 2, attemptCount)
// Verify that the client used is the custom one by checking newRetryableHTTPClient
retrievedRetryClient, err := client.newRetryableHTTPClient()
require.NoError(t, err)
assert.Equal(t, customRetryClient, retrievedRetryClient, "should return the custom retryable client")
})
t.Run("respects custom client's retry configuration over built-in defaults", func(t *testing.T) {
attemptCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attemptCount++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
// Create custom client with limited retries
customRetryClient := retryablehttp.NewClient()
customRetryClient.RetryMax = 1 // Only 1 retry (2 total attempts)
customRetryClient.RetryWaitMax = 5 * time.Millisecond
opts := defaultHTTPClientOption()
WithRetryableHTTPClint(customRetryClient)(&opts)
client := newCommonClient(testSystemInfo, opts)
req, err := http.NewRequest("GET", server.URL, nil)
require.NoError(t, err)
resp, err := client.do(req)
// When all retries are exhausted with a retryable error, the client gives up
// and an error is returned
if err != nil {
// Expected: request failed after exhausting retries
assert.Contains(t, err.Error(), "giving up after 2 attempt(s)")
} else {
// Or the final response is returned
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
// Should have tried 1 initial + 1 retry = 2 times total
assert.Equal(t, 2, attemptCount)
})
}
+212 -24
View File
@@ -1,6 +1,6 @@
module github.com/actions/scaleset
go 1.25.3
go 1.26.2
require (
github.com/docker/docker v28.5.2+incompatible
@@ -8,32 +8,132 @@ require (
github.com/google/go-github/v79 v79.0.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.47.0
golang.org/x/net v0.53.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/brunoga/deep v1.2.4 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v0.1.0 // indirect
github.com/knadh/koanf/providers/env v1.0.0 // indirect
@@ -41,49 +141,137 @@ require (
github.com/knadh/koanf/providers/posflag v0.1.0 // indirect
github.com/knadh/koanf/providers/structs v0.1.0 // indirect
github.com/knadh/koanf/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tetafro/godot v1.5.4 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/vektra/mockery/v3 v3.6.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/grpc v1.72.2 // indirect
google.golang.org/protobuf v1.36.9 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
honnef.co/go/tools v0.7.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
)
tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/vektra/mockery/v3
golang.org/x/tools/cmd/deadcode
)
+943 -50
View File
File diff suppressed because it is too large. Load diff
+89 -10
View File
@@ -49,25 +49,61 @@ func (c *Config) Validate() error {
type Client interface {
GetMessage(ctx context.Context, lastMessageID, maxCapacity int) (*scaleset.RunnerScaleSetMessage, error)
DeleteMessage(ctx context.Context, messageID int) error
AcquireJobs(ctx context.Context, requestIDs []int64) ([]int64, error)
Session() scaleset.RunnerScaleSetSession
}
type Option func(*Listener)
// MetricsRecorder defines the hook methods that will be called by the listener at
// various points in the message handling process. This allows for custom
// metrics to be collected without coupling the listener to a specific metrics
// implementation. The methods in this interface will be called with relevant
// information about the message handling process, such as the number of jobs
// started/completed, the desired runner count, and any errors that occur.
// Implementers can use this information to track the performance and behavior
// of the listener and the scaleset service.
type MetricsRecorder interface {
RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic)
RecordJobStarted(msg *scaleset.JobStarted)
RecordJobCompleted(msg *scaleset.JobCompleted)
RecordDesiredRunners(count int)
}
type discardMetricsRecorder struct{}
func (d *discardMetricsRecorder) RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic) {}
func (d *discardMetricsRecorder) RecordJobStarted(msg *scaleset.JobStarted) {}
func (d *discardMetricsRecorder) RecordJobCompleted(msg *scaleset.JobCompleted) {}
func (d *discardMetricsRecorder) RecordDesiredRunners(count int) {}
// Listener listens for messages from the scaleset service and handles them. It automatically handles session
// creation/deletion/refreshing and message polling and acking.
type Listener struct {
// The main client responsible for communicating with the scaleset service
client Client
client Client
metricsRecorder MetricsRecorder
// Configuration for the listener
scaleSetID int
maxRunners atomic.Uint32
scaleSetID int
maxRunners atomic.Uint32
latestStatistics *scaleset.RunnerScaleSetStatistic
// configuration for the listener
logger *slog.Logger
}
type Option func(*Listener)
// WithMetricsRecorder sets the MetricsRecorder for the listener. If not set, a no-op recorder will be used.
// If the nil is passed, the MetricsRecorder will not be updated and the existing one will be used (which is a no-op by default).
func WithMetricsRecorder(recorder MetricsRecorder) Option {
return func(l *Listener) {
if recorder == nil {
return
}
l.metricsRecorder = recorder
}
}
// SetMaxRunners sets the capacity of the scaleset. It is concurrently
// safe to update the max runners during listener.Run.
func (l *Listener) SetMaxRunners(count int) {
@@ -85,12 +121,17 @@ func New(client Client, config Config, options ...Option) (*Listener, error) {
}
listener := &Listener{
client: client,
scaleSetID: config.ScaleSetID,
logger: config.Logger,
client: client,
metricsRecorder: &discardMetricsRecorder{},
scaleSetID: config.ScaleSetID,
logger: config.Logger,
}
listener.SetMaxRunners(config.MaxRunners)
for _, option := range options {
option(listener)
}
return listener, nil
}
@@ -114,13 +155,17 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
return fmt.Errorf("session statistics is nil")
}
l.handleStatistics(ctx, initialSession.Statistics)
l.logger.Info(
"Handling initial session statistics",
slog.Int("totalAssignedJobs", initialSession.Statistics.TotalAssignedJobs),
)
if _, err := scaler.HandleDesiredRunnerCount(ctx, initialSession.Statistics.TotalAssignedJobs); err != nil {
desiredCount, err := scaler.HandleDesiredRunnerCount(ctx, initialSession.Statistics.TotalAssignedJobs)
if err != nil {
return fmt.Errorf("handling initial message failed: %w", err)
}
l.metricsRecorder.RecordDesiredRunners(desiredCount)
}
var lastMessageID int
@@ -142,7 +187,7 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
}
if msg == nil {
_, err := scaler.HandleDesiredRunnerCount(ctx, 0)
_, err := scaler.HandleDesiredRunnerCount(ctx, l.latestStatistics.TotalAssignedJobs)
if err != nil {
return fmt.Errorf("handling nil message failed: %w", err)
}
@@ -160,24 +205,58 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
}
func (l *Listener) handleMessage(ctx context.Context, handler Scaler, msg *scaleset.RunnerScaleSetMessage) error {
l.handleStatistics(ctx, msg.Statistics)
if err := l.client.DeleteMessage(ctx, msg.MessageID); err != nil {
return fmt.Errorf("failed to delete message: %w", err)
}
if len(msg.JobAvailableMessages) > 0 {
if err := l.acquireAvailableJobs(ctx, msg.JobAvailableMessages); err != nil {
return fmt.Errorf("failed to acquire available jobs: %w", err)
}
}
for _, jobStarted := range msg.JobStartedMessages {
l.metricsRecorder.RecordJobStarted(jobStarted)
if err := handler.HandleJobStarted(ctx, jobStarted); err != nil {
return fmt.Errorf("failed to handle job started: %w", err)
}
}
for _, jobCompleted := range msg.JobCompletedMessages {
l.metricsRecorder.RecordJobCompleted(jobCompleted)
if err := handler.HandleJobCompleted(ctx, jobCompleted); err != nil {
return fmt.Errorf("failed to handle job completed: %w", err)
}
}
if _, err := handler.HandleDesiredRunnerCount(ctx, msg.Statistics.TotalAssignedJobs); err != nil {
desiredCount, err := handler.HandleDesiredRunnerCount(ctx, msg.Statistics.TotalAssignedJobs)
if err != nil {
return fmt.Errorf("failed to handle desired runner count: %w", err)
}
l.metricsRecorder.RecordDesiredRunners(desiredCount)
return nil
}
func (l *Listener) acquireAvailableJobs(ctx context.Context, jobsAvailable []*scaleset.JobAvailable) error {
ids := make([]int64, 0, len(jobsAvailable))
for _, job := range jobsAvailable {
ids = append(ids, job.RunnerRequestID)
}
l.logger.Info("Acquiring jobs", slog.Int("count", len(ids)))
acquired, err := l.client.AcquireJobs(ctx, ids)
if err != nil {
return fmt.Errorf("acquiring jobs: %w", err)
}
l.logger.Info("Jobs acquired", slog.Int("count", len(acquired)))
return nil
}
func (l *Listener) handleStatistics(ctx context.Context, msg *scaleset.RunnerScaleSetStatistic) {
l.latestStatistics = msg
l.metricsRecorder.RecordStatistics(msg)
}
+51 -54
View File
@@ -75,39 +75,37 @@ func TestListener_Run(t *testing.T) {
client := NewMockClient(t)
uuid := uuid.New()
initialStatistics := &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 2,
}
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: &scaleset.RunnerScaleSetStatistic{},
Statistics: initialStatistics,
}
client.On("Session").Return(session).Once()
l, err := New(client, config)
metricsRecorder := NewMockMetricsRecorder(t)
metricsRecorder.On("RecordStatistics", initialStatistics).Once()
metricsRecorder.On("RecordDesiredRunners", initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Run(func(mock.Arguments) { cancel() }).
Once()
l, err := New(client, config, WithMetricsRecorder(metricsRecorder))
require.Nil(t, err)
var called bool
handler := NewMockScaler(t)
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
mock.Anything,
).
Return(0, nil).
Run(
func(mock.Arguments) {
called = true
cancel()
},
).
handler.On("HandleDesiredRunnerCount", mock.Anything, mock.Anything).
Return(initialStatistics.TotalAssignedJobs, nil).
Once()
err = l.Run(ctx, handler)
assert.ErrorIs(t, err, context.Canceled)
assert.True(t, called)
})
t.Run("cancel context after get message", func(t *testing.T) {
@@ -120,61 +118,60 @@ func TestListener_Run(t *testing.T) {
MaxRunners: 10,
}
client := NewMockClient(t)
uuid := uuid.New()
initialStatistics := &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 2,
}
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: &scaleset.RunnerScaleSetStatistic{},
Statistics: initialStatistics,
}
msg := &scaleset.RunnerScaleSetMessage{
MessageID: 1,
Statistics: &scaleset.RunnerScaleSetStatistic{},
MessageID: 1,
Statistics: &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 3,
},
}
client.On("Session").Return(session).Once()
client.On(
"GetMessage",
ctx,
mock.Anything,
10,
).
Return(msg, nil).
Run(
func(mock.Arguments) {
cancel()
},
).
Once()
// Ensure delete message is called without cancel
client.On(
"DeleteMessage",
context.WithoutCancel(ctx),
mock.Anything,
).Return(nil).Once()
metricsRecorder := NewMockMetricsRecorder(t)
client := NewMockClient(t)
handler := NewMockScaler(t)
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
0,
).
Return(0, nil).
client.On("Session").Return(session).Once()
metricsRecorder.On("RecordStatistics", initialStatistics).Once()
metricsRecorder.On("RecordDesiredRunners", initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Once()
handler.On("HandleDesiredRunnerCount", mock.Anything, initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Once()
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
mock.Anything,
).
Return(0, nil).
client.On("GetMessage", ctx, mock.Anything, 10).
Return(msg, nil).
Run(func(mock.Arguments) { cancel() }).
Once()
l, err := New(client, config)
metricsRecorder.On("RecordStatistics", msg.Statistics).Once()
// Ensure delete message is called without cancel
client.On("DeleteMessage", context.WithoutCancel(ctx), mock.Anything).
Return(nil).
Once()
metricsRecorder.On("RecordDesiredRunners", msg.Statistics.TotalAssignedJobs).
Return(msg.Statistics.TotalAssignedJobs, nil).
Once()
handler.On("HandleDesiredRunnerCount", mock.Anything, msg.Statistics.TotalAssignedJobs).
Return(msg.Statistics.TotalAssignedJobs, nil).
Once()
l, err := New(client, config, WithMetricsRecorder(metricsRecorder))
require.Nil(t, err)
err = l.Run(ctx, handler)
+255
View File
@@ -38,6 +38,74 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// AcquireJobs provides a mock function for the type MockClient
func (_mock *MockClient) AcquireJobs(ctx context.Context, requestIDs []int64) ([]int64, error) {
ret := _mock.Called(ctx, requestIDs)
if len(ret) == 0 {
panic("no return value specified for AcquireJobs")
}
var r0 []int64
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) ([]int64, error)); ok {
return returnFunc(ctx, requestIDs)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) []int64); ok {
r0 = returnFunc(ctx, requestIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int64)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, []int64) error); ok {
r1 = returnFunc(ctx, requestIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_AcquireJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AcquireJobs'
type MockClient_AcquireJobs_Call struct {
*mock.Call
}
// AcquireJobs is a helper method to define mock.On call
// - ctx context.Context
// - requestIDs []int64
func (_e *MockClient_Expecter) AcquireJobs(ctx interface{}, requestIDs interface{}) *MockClient_AcquireJobs_Call {
return &MockClient_AcquireJobs_Call{Call: _e.mock.On("AcquireJobs", ctx, requestIDs)}
}
func (_c *MockClient_AcquireJobs_Call) Run(run func(ctx context.Context, requestIDs []int64)) *MockClient_AcquireJobs_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 []int64
if args[1] != nil {
arg1 = args[1].([]int64)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockClient_AcquireJobs_Call) Return(int64s []int64, err error) *MockClient_AcquireJobs_Call {
_c.Call.Return(int64s, err)
return _c
}
func (_c *MockClient_AcquireJobs_Call) RunAndReturn(run func(ctx context.Context, requestIDs []int64) ([]int64, error)) *MockClient_AcquireJobs_Call {
_c.Call.Return(run)
return _c
}
// DeleteMessage provides a mock function for the type MockClient
func (_mock *MockClient) DeleteMessage(ctx context.Context, messageID int) error {
ret := _mock.Called(ctx, messageID)
@@ -213,6 +281,193 @@ func (_c *MockClient_Session_Call) RunAndReturn(run func() scaleset.RunnerScaleS
return _c
}
// NewMockMetricsRecorder creates a new instance of MockMetricsRecorder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockMetricsRecorder(t interface {
mock.TestingT
Cleanup(func())
}) *MockMetricsRecorder {
mock := &MockMetricsRecorder{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockMetricsRecorder is an autogenerated mock type for the MetricsRecorder type
type MockMetricsRecorder struct {
mock.Mock
}
type MockMetricsRecorder_Expecter struct {
mock *mock.Mock
}
func (_m *MockMetricsRecorder) EXPECT() *MockMetricsRecorder_Expecter {
return &MockMetricsRecorder_Expecter{mock: &_m.Mock}
}
// RecordDesiredRunners provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordDesiredRunners(count int) {
_mock.Called(count)
return
}
// MockMetricsRecorder_RecordDesiredRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordDesiredRunners'
type MockMetricsRecorder_RecordDesiredRunners_Call struct {
*mock.Call
}
// RecordDesiredRunners is a helper method to define mock.On call
// - count int
func (_e *MockMetricsRecorder_Expecter) RecordDesiredRunners(count interface{}) *MockMetricsRecorder_RecordDesiredRunners_Call {
return &MockMetricsRecorder_RecordDesiredRunners_Call{Call: _e.mock.On("RecordDesiredRunners", count)}
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) Run(run func(count int)) *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 int
if args[0] != nil {
arg0 = args[0].(int)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) Return() *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) RunAndReturn(run func(count int)) *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Run(run)
return _c
}
// RecordJobCompleted provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordJobCompleted(msg *scaleset.JobCompleted) {
_mock.Called(msg)
return
}
// MockMetricsRecorder_RecordJobCompleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordJobCompleted'
type MockMetricsRecorder_RecordJobCompleted_Call struct {
*mock.Call
}
// RecordJobCompleted is a helper method to define mock.On call
// - msg *scaleset.JobCompleted
func (_e *MockMetricsRecorder_Expecter) RecordJobCompleted(msg interface{}) *MockMetricsRecorder_RecordJobCompleted_Call {
return &MockMetricsRecorder_RecordJobCompleted_Call{Call: _e.mock.On("RecordJobCompleted", msg)}
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) Run(run func(msg *scaleset.JobCompleted)) *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.JobCompleted
if args[0] != nil {
arg0 = args[0].(*scaleset.JobCompleted)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) Return() *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) RunAndReturn(run func(msg *scaleset.JobCompleted)) *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Run(run)
return _c
}
// RecordJobStarted provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordJobStarted(msg *scaleset.JobStarted) {
_mock.Called(msg)
return
}
// MockMetricsRecorder_RecordJobStarted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordJobStarted'
type MockMetricsRecorder_RecordJobStarted_Call struct {
*mock.Call
}
// RecordJobStarted is a helper method to define mock.On call
// - msg *scaleset.JobStarted
func (_e *MockMetricsRecorder_Expecter) RecordJobStarted(msg interface{}) *MockMetricsRecorder_RecordJobStarted_Call {
return &MockMetricsRecorder_RecordJobStarted_Call{Call: _e.mock.On("RecordJobStarted", msg)}
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) Run(run func(msg *scaleset.JobStarted)) *MockMetricsRecorder_RecordJobStarted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.JobStarted
if args[0] != nil {
arg0 = args[0].(*scaleset.JobStarted)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) Return() *MockMetricsRecorder_RecordJobStarted_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) RunAndReturn(run func(msg *scaleset.JobStarted)) *MockMetricsRecorder_RecordJobStarted_Call {
_c.Run(run)
return _c
}
// RecordStatistics provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic) {
_mock.Called(statistics)
return
}
// MockMetricsRecorder_RecordStatistics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordStatistics'
type MockMetricsRecorder_RecordStatistics_Call struct {
*mock.Call
}
// RecordStatistics is a helper method to define mock.On call
// - statistics *scaleset.RunnerScaleSetStatistic
func (_e *MockMetricsRecorder_Expecter) RecordStatistics(statistics interface{}) *MockMetricsRecorder_RecordStatistics_Call {
return &MockMetricsRecorder_RecordStatistics_Call{Call: _e.mock.On("RecordStatistics", statistics)}
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) Run(run func(statistics *scaleset.RunnerScaleSetStatistic)) *MockMetricsRecorder_RecordStatistics_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.RunnerScaleSetStatistic
if args[0] != nil {
arg0 = args[0].(*scaleset.RunnerScaleSetStatistic)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) Return() *MockMetricsRecorder_RecordStatistics_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) RunAndReturn(run func(statistics *scaleset.RunnerScaleSetStatistic)) *MockMetricsRecorder_RecordStatistics_Call {
_c.Run(run)
return _c
}
// NewMockScaler creates a new instance of MockScaler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockScaler(t interface {
+64
View File
@@ -234,7 +234,71 @@ func (c *MessageSessionClient) Session() RunnerScaleSetSession {
return *c.session
}
// AcquireJobs acquires the given job request IDs from the runner scale set.
// If the current session token is expired, it refreshes the session and tries one more time.
func (c *MessageSessionClient) AcquireJobs(ctx context.Context, requestIDs []int64) ([]int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
ids, err := c.acquireJobs(ctx, requestIDs)
if err == nil {
return ids, nil
}
if !errors.Is(err, MessageQueueTokenExpiredError) {
return nil, fmt.Errorf("failed to acquire jobs: %w", err)
}
if err := c.refreshMessageSession(ctx); err != nil {
return nil, fmt.Errorf("failed to refresh message session: %w", err)
}
return c.acquireJobs(ctx, requestIDs)
}
func (c *MessageSessionClient) acquireJobs(ctx context.Context, requestIDs []int64) ([]int64, error) {
body, err := json.Marshal(requestIDs)
if err != nil {
return nil, fmt.Errorf("failed to marshal request ids: %w", err)
}
path := fmt.Sprintf("/%s/%d/acquirejobs", scaleSetEndpoint, c.scaleSetID)
c.innerClient.mu.Lock()
req, err := c.innerClient.newActionsServiceRequest(ctx, http.MethodPost, path, bytes.NewBuffer(body))
c.innerClient.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("failed to create acquire jobs request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.session.MessageQueueAccessToken))
resp, err := c.commonClient.do(req)
if err != nil {
return nil, fmt.Errorf("failed to issue acquire jobs request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, newRequestResponseError(req, resp, MessageQueueTokenExpiredError)
}
if resp.StatusCode != http.StatusOK {
return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code %s", resp.Status))
}
var result acquireJobsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode acquire jobs response: %w", err))
}
return result.Value, nil
}
func (c *MessageSessionClient) doSessionRequest(ctx context.Context, method, path string, requestData io.Reader, expectedResponseStatusCode int, responseUnmarshalTarget any) error {
c.innerClient.mu.Lock()
defer c.innerClient.mu.Unlock()
req, err := c.innerClient.newActionsServiceRequest(ctx, method, path, requestData)
if err != nil {
return fmt.Errorf("failed to create new actions service request: %w", err)
+228
View File
@@ -649,3 +649,231 @@ func TestDeleteMessage(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected status code")
})
}
func TestAcquireJobs(t *testing.T) {
ctx := context.Background()
auth := actionsAuth{
token: "token",
}
t.Run("Acquire jobs successfully", func(t *testing.T) {
requestIDs := []int64{1, 2}
want := []int64{1, 2}
response := []byte(`{"count":2,"value":[1,2]}`)
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "acquirejobs") {
assert.Equal(t, http.MethodPost, r.Method)
assert.Contains(t, r.URL.Path, "acquirejobs")
assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer"), "expected Bearer authorization header")
var gotIDs []int64
err := json.NewDecoder(r.Body).Decode(&gotIDs)
require.NoError(t, err)
assert.Equal(t, requestIDs, gotIDs)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(response)
return
}
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
if strings.Contains(r.URL.Path, "/sessions/") {
handleSessionRequest(w, r)
return
}
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.AcquireJobs(ctx, requestIDs)
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("Message token expired", func(t *testing.T) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "acquirejobs") {
w.WriteHeader(http.StatusUnauthorized)
return
}
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.AcquireJobs(ctx, []int64{1})
assert.Nil(t, got)
assert.ErrorIs(t, err, MessageQueueTokenExpiredError, "expected error to be MessageQueueTokenExpiredError but got: %v", err)
})
t.Run("Message token refreshed", func(t *testing.T) {
want := []int64{1, 2}
afterRefreshResponse := []byte(`{"count":2,"value":[1,2]}`)
var handleSessionRequest http.HandlerFunc
type state int
const (
createSession state = iota
firstAcquire
refreshToken
secondAcquire
)
currentState := createSession
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "acquirejobs") {
if currentState == firstAcquire {
w.WriteHeader(http.StatusUnauthorized)
currentState = refreshToken
return
}
require.Equal(t, secondAcquire, currentState)
w.Header().Set("Content-Type", "application/json")
w.Write(afterRefreshResponse)
return
}
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
require.Equal(t, createSession, currentState)
handleSessionRequest(w, r)
currentState = firstAcquire
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
require.Equal(t, refreshToken, currentState)
handleSessionRequest(w, r)
currentState = secondAcquire
return
}
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.AcquireJobs(ctx, []int64{1, 2})
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("Server error", func(t *testing.T) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "acquirejobs") {
w.WriteHeader(http.StatusInternalServerError)
return
}
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
if strings.Contains(r.URL.Path, "/sessions/") {
handleSessionRequest(w, r)
return
}
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
retryMax := 1
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Nanosecond),
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(
ctx,
1,
"my-org",
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Nanosecond),
)
require.NoError(t, err)
got, err := sessionClient.AcquireJobs(ctx, []int64{1})
assert.Nil(t, got)
assert.NotNil(t, err)
})
t.Run("Empty request IDs", func(t *testing.T) {
response := []byte(`{"count":0,"value":[]}`)
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "acquirejobs") {
assert.Equal(t, http.MethodPost, r.Method)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(response)
return
}
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
if strings.Contains(r.URL.Path, "/sessions/") {
handleSessionRequest(w, r)
return
}
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.AcquireJobs(ctx, []int64{})
require.NoError(t, err)
assert.Empty(t, got)
})
}
+12
View File
@@ -12,11 +12,17 @@ type MessageType string
// message types
const (
MessageTypeJobAvailable MessageType = "JobAvailable"
MessageTypeJobAssigned MessageType = "JobAssigned"
MessageTypeJobStarted MessageType = "JobStarted"
MessageTypeJobCompleted MessageType = "JobCompleted"
)
type JobAvailable struct {
AcquireJobURL string `json:"acquireJobUrl"`
JobMessageBase
}
type JobAssigned struct {
JobMessageBase
}
@@ -99,6 +105,7 @@ type runnerScaleSetMessageResponse struct {
type RunnerScaleSetMessage struct {
MessageID int
Statistics *RunnerScaleSetStatistic
JobAvailableMessages []*JobAvailable
JobAssignedMessages []*JobAssigned
JobStartedMessages []*JobStarted
JobCompletedMessages []*JobCompleted
@@ -109,6 +116,11 @@ type runnerScaleSetsResponse struct {
RunnerScaleSets []RunnerScaleSet `json:"value"`
}
type acquireJobsResponse struct {
Count int `json:"count"`
Value []int64 `json:"value"`
}
type RunnerScaleSetSession struct {
SessionID uuid.UUID `json:"sessionId,omitempty"`
OwnerName string `json:"ownerName,omitempty"`