Skip to content

Commit 94be6ef

Browse files
committed
fix: scope offloaded node status query to the page. Fixes #16611
Signed-off-by: HsiuChuanHsu <hchsu2106@gmail.com>
1 parent 854f44f commit 94be6ef

10 files changed

Lines changed: 253 additions & 25 deletions

persist/sqldb/explosive_offload_node_status_repo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func (n *explosiveOffloadNodeStatusRepo) Get(context.Context, string, string) (w
2626
return nil, ErrOffloadNotSupported
2727
}
2828

29-
func (n *explosiveOffloadNodeStatusRepo) List(context.Context, string) (map[UUIDVersion]wfv1.Nodes, error) {
29+
func (n *explosiveOffloadNodeStatusRepo) List(context.Context, string, []UUIDVersion) (map[UUIDVersion]wfv1.Nodes, error) {
3030
return nil, ErrOffloadNotSupported
3131
}
3232

persist/sqldb/mocks/OffloadNodeStatusRepo.go

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

persist/sqldb/offload_node_status_repo.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type UUIDVersion struct {
2626
type OffloadNodeStatusRepo interface {
2727
Save(ctx context.Context, uid, namespace string, nodes wfv1.Nodes) (string, error)
2828
Get(ctx context.Context, uid, version string) (wfv1.Nodes, error)
29-
List(ctx context.Context, namespace string) (map[UUIDVersion]wfv1.Nodes, error)
29+
List(ctx context.Context, namespace string, keys []UUIDVersion) (map[UUIDVersion]wfv1.Nodes, error)
3030
ListOldOffloads(ctx context.Context, namespace string) (map[string][]string, error)
3131
Delete(ctx context.Context, uid, version string) error
3232
IsEnabled() bool
@@ -149,8 +149,28 @@ func (wdc *nodeOffloadRepo) Get(ctx context.Context, uid, version string) (wfv1.
149149
return nodes, nil
150150
}
151151

152-
func (wdc *nodeOffloadRepo) List(ctx context.Context, namespace string) (map[UUIDVersion]wfv1.Nodes, error) {
153-
wdc.log.WithFields(logging.Fields{"namespace": namespace}).Debug(ctx, "Listing offloaded nodes")
152+
// uuidVersionIn matches exactly the given (uid, version) pairs. Written as OR-of-ANDs rather
153+
// than a row value `(uid, version) IN ((?,?),...)` so that it behaves the same on MySQL,
154+
// MariaDB, Postgres and SQLite.
155+
//
156+
// Each pair costs two placeholders, so this tops out at roughly 32k pairs on MySQL. A page
157+
// that large is not a real scenario; batch the keys here if that ever changes.
158+
func uuidVersionIn(keys []UUIDVersion) db.LogicalExpr {
159+
conds := make([]db.LogicalExpr, len(keys))
160+
for i, key := range keys {
161+
conds[i] = db.And(db.Cond{"uid": key.UID}, db.Cond{"version": key.Version})
162+
}
163+
return db.Or(conds...)
164+
}
165+
166+
// List returns the offloaded nodes for the given keys only.
167+
func (wdc *nodeOffloadRepo) List(ctx context.Context, namespace string, keys []UUIDVersion) (map[UUIDVersion]wfv1.Nodes, error) {
168+
// This is not merely an optimisation: db.Or() with no arguments is an empty condition,
169+
// so the query would silently widen back to every nodes blob in the namespace.
170+
if len(keys) == 0 {
171+
return map[UUIDVersion]wfv1.Nodes{}, nil
172+
}
173+
wdc.log.WithFields(logging.Fields{"namespace": namespace, "keys": len(keys)}).Debug(ctx, "Listing offloaded nodes")
154174
var res map[UUIDVersion]wfv1.Nodes
155175
err := wdc.sessionProxy.With(ctx, func(s db.Session) error {
156176
var records []nodesRecord
@@ -159,6 +179,7 @@ func (wdc *nodeOffloadRepo) List(ctx context.Context, namespace string) (map[UUI
159179
From(wdc.tableName).
160180
Where(db.Cond{"clustername": wdc.clusterName}).
161181
And(namespaceEqual(namespace)).
182+
And(uuidVersionIn(keys)).
162183
All(&records)
163184
if err != nil {
164185
return err
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//go:build !windows
2+
3+
package sqldb
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
13+
"github.com/argoproj/argo-workflows/v4/util/logging"
14+
usqldb "github.com/argoproj/argo-workflows/v4/util/sqldb"
15+
)
16+
17+
// setupMySQLOffloadTest starts a MySQL or MariaDB container and returns an offload repository.
18+
func setupMySQLOffloadTest(ctx context.Context, t *testing.T, v usqldb.MySQLVariant) OffloadNodeStatusRepo {
19+
t.Helper()
20+
repo, err := NewOffloadNodeStatusRepo(ctx, logging.RequireLoggerFromContext(ctx), setupMySQLTest(ctx, t, v), "test", "argo_workflows")
21+
require.NoError(t, err)
22+
return repo
23+
}
24+
25+
// saveOffload writes a node status and returns the version it was stored under.
26+
func saveOffload(ctx context.Context, t *testing.T, repo OffloadNodeStatusRepo, uid, nodeName string) string {
27+
t.Helper()
28+
version, err := repo.Save(ctx, uid, "argo", wfv1.Nodes{nodeName: wfv1.NodeStatus{ID: nodeName}})
29+
require.NoError(t, err)
30+
return version
31+
}
32+
33+
// TestMySQLListOnlyReturnsRequestedKeys covers the behaviour the list path depends on: the
34+
// query is scoped to the keys it is given, so a caller that needs one page of workflows does
35+
// not pull every offloaded blob in the namespace.
36+
func TestMySQLListOnlyReturnsRequestedKeys(t *testing.T) {
37+
for name, variant := range usqldb.MySQLVariants {
38+
t.Run(name, func(t *testing.T) {
39+
ctx := logging.TestContext(t.Context())
40+
repo := setupMySQLOffloadTest(ctx, t, variant)
41+
42+
wantedA := UUIDVersion{UID: "uid-a", Version: saveOffload(ctx, t, repo, "uid-a", "node-a")}
43+
wantedB := UUIDVersion{UID: "uid-b", Version: saveOffload(ctx, t, repo, "uid-b", "node-b")}
44+
unwanted := UUIDVersion{UID: "uid-c", Version: saveOffload(ctx, t, repo, "uid-c", "node-c")}
45+
46+
got, err := repo.List(ctx, "argo", []UUIDVersion{wantedA, wantedB})
47+
require.NoError(t, err)
48+
49+
assert.Len(t, got, 2)
50+
assert.Contains(t, got, wantedA)
51+
assert.Contains(t, got, wantedB)
52+
assert.NotContains(t, got, unwanted, "a key that was not asked for must not come back")
53+
})
54+
}
55+
}
56+
57+
// TestMySQLListExcludesSupersededVersions pins down why the version has to be part of the
58+
// condition. Older offloads of the same workflow stay in the table until they are garbage
59+
// collected, so filtering on the uid alone would drag those blobs back too.
60+
// Dialect portability is already covered on both engines by TestMySQLListOnlyReturnsRequestedKeys,
61+
// so one engine is enough here.
62+
func TestMySQLListExcludesSupersededVersions(t *testing.T) {
63+
ctx := logging.TestContext(t.Context())
64+
repo := setupMySQLOffloadTest(ctx, t, usqldb.MySQLVariants["MySQL"])
65+
66+
oldVersion := saveOffload(ctx, t, repo, "uid-a", "node-old")
67+
newVersion := saveOffload(ctx, t, repo, "uid-a", "node-new")
68+
require.NotEqual(t, oldVersion, newVersion, "the two writes must produce different versions")
69+
70+
got, err := repo.List(ctx, "argo", []UUIDVersion{{UID: "uid-a", Version: newVersion}})
71+
require.NoError(t, err)
72+
73+
assert.Len(t, got, 1)
74+
assert.Contains(t, got, UUIDVersion{UID: "uid-a", Version: newVersion})
75+
assert.NotContains(t, got, UUIDVersion{UID: "uid-a", Version: oldVersion})
76+
}

persist/sqldb/offload_node_status_repo_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@ import (
77
"github.com/stretchr/testify/require"
88

99
wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
10+
"github.com/argoproj/argo-workflows/v4/util/logging"
1011
)
1112

13+
// Test_ListWithoutKeys guards the empty-keys early return. Without it the generated condition
14+
// is empty, which would widen the query back to the whole namespace. A zero-value repo has no
15+
// session, so anything past the guard panics rather than quietly querying.
16+
func Test_ListWithoutKeys(t *testing.T) {
17+
got, err := (&nodeOffloadRepo{}).List(logging.TestContext(t.Context()), "argo", nil)
18+
require.NoError(t, err)
19+
assert.Empty(t, got)
20+
}
21+
1222
func Test_nodeStatusVersion(t *testing.T) {
1323
t.Run("Empty", func(t *testing.T) {
1424
marshalled, version, err := nodeStatusVersion(nil)

persist/sqldb/workflow_archive_mysql_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import (
2727
// setupMySQLArchiveTest starts a MySQL or MariaDB container, runs migrations, and returns a WorkflowArchive.
2828
func setupMySQLArchiveTest(ctx context.Context, t *testing.T, v usqldb.MySQLVariant) WorkflowArchive {
2929
t.Helper()
30+
return NewWorkflowArchive(setupMySQLTest(ctx, t, v), "test", "", instanceid.NewService(""))
31+
}
32+
33+
// setupMySQLTest starts a MySQL or MariaDB container, runs migrations, and returns a session
34+
// proxy the caller can build any repository on.
35+
func setupMySQLTest(ctx context.Context, t *testing.T, v usqldb.MySQLVariant) *usqldb.SessionProxy {
36+
t.Helper()
3037

3138
c, err := testmysql.Run(ctx,
3239
v.Image,
@@ -73,7 +80,7 @@ func setupMySQLArchiveTest(ctx context.Context, t *testing.T, v usqldb.MySQLVari
7380

7481
t.Cleanup(func() { proxy.Close() })
7582

76-
return NewWorkflowArchive(proxy, "test", "", instanceid.NewService(""))
83+
return proxy
7784
}
7885

7986
// TestMySQLListWorkflows verifies that JSON_EXTRACT/JSON_UNQUOTE queries in

server/workflow/workflow_server.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"maps"
9+
"slices"
810
"sort"
911
"sync"
1012
"time"
@@ -285,19 +287,22 @@ func (s *workflowServer) ListWorkflows(ctx context.Context, req *workflowpkg.Wor
285287
}
286288

287289
cleaner := fields.NewCleaner(req.Fields)
288-
logger := logging.RequireLoggerFromContext(ctx)
289290
if s.offloadNodeStatusRepo.IsEnabled() && !cleaner.WillExclude("items.status.nodes") {
290-
offloadedNodes, err := s.offloadNodeStatusRepo.List(ctx, req.Namespace)
291-
if err != nil {
292-
return nil, sutils.ToStatusError(err, codes.Internal)
293-
}
291+
// This page is already resolved, so we know exactly which offloaded rows we need.
292+
offloaded := map[int]sqldb.UUIDVersion{}
294293
for i, wf := range wfs {
295294
if wf.Status.IsOffloadNodeStatus() {
296-
if s.offloadNodeStatusRepo.IsEnabled() {
297-
wfs[i].Status.Nodes = offloadedNodes[sqldb.UUIDVersion{UID: string(wf.UID), Version: wf.GetOffloadNodeStatusVersion()}]
298-
} else {
299-
logger.WithFields(logging.Fields{"namespace": wf.Namespace, "name": wf.Name}).Warn(ctx, sqldb.OffloadNodeStatusDisabled)
300-
}
295+
offloaded[i] = sqldb.UUIDVersion{UID: string(wf.UID), Version: wf.GetOffloadNodeStatusVersion()}
296+
}
297+
}
298+
// Nothing on this page is offloaded, so there is nothing to fetch.
299+
if len(offloaded) > 0 {
300+
offloadedNodes, err := s.offloadNodeStatusRepo.List(ctx, req.Namespace, slices.Collect(maps.Values(offloaded)))
301+
if err != nil {
302+
return nil, sutils.ToStatusError(err, codes.Internal)
303+
}
304+
for i, key := range offloaded {
305+
wfs[i].Status.Nodes = offloadedNodes[key]
301306
}
302307
}
303308
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package workflow
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/go-jose/go-jose/v4/jwt"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/mock"
10+
"github.com/stretchr/testify/require"
11+
authorizationv1 "k8s.io/api/authorization/v1"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/types"
15+
"k8s.io/client-go/kubernetes/fake"
16+
ktesting "k8s.io/client-go/testing"
17+
18+
"github.com/argoproj/argo-workflows/v4/persist/sqldb"
19+
"github.com/argoproj/argo-workflows/v4/persist/sqldb/mocks"
20+
workflowpkg "github.com/argoproj/argo-workflows/v4/pkg/apiclient/workflow"
21+
"github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
22+
v1alpha "github.com/argoproj/argo-workflows/v4/pkg/client/clientset/versioned/fake"
23+
"github.com/argoproj/argo-workflows/v4/server/auth"
24+
authtypes "github.com/argoproj/argo-workflows/v4/server/auth/types"
25+
"github.com/argoproj/argo-workflows/v4/server/clusterworkflowtemplate"
26+
"github.com/argoproj/argo-workflows/v4/server/workflow/store"
27+
"github.com/argoproj/argo-workflows/v4/server/workflowtemplate"
28+
"github.com/argoproj/argo-workflows/v4/util/instanceid"
29+
"github.com/argoproj/argo-workflows/v4/util/logging"
30+
)
31+
32+
// offloadedWorkflow builds a workflow whose node status lives in the offload table.
33+
func offloadedWorkflow(name, uid, version string) v1alpha1.Workflow {
34+
return v1alpha1.Workflow{
35+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "argo", UID: types.UID(uid)},
36+
Status: v1alpha1.WorkflowStatus{OffloadNodeStatusVersion: version},
37+
}
38+
}
39+
40+
// offloadTestServer builds the smallest server that can serve ListWorkflows, and hands back
41+
// the offload mock so tests can assert on how it was called. It deliberately does not reuse
42+
// getWorkflowServer, which is shared by many other tests and does not expose the mock.
43+
func offloadTestServer(t *testing.T, wfs ...v1alpha1.Workflow) (Server, context.Context, *mocks.OffloadNodeStatusRepo) {
44+
t.Helper()
45+
46+
offloadNodeStatusRepo := &mocks.OffloadNodeStatusRepo{}
47+
offloadNodeStatusRepo.On("IsEnabled", mock.Anything).Return(true)
48+
49+
archivedRepo := &mocks.WorkflowArchive{}
50+
archivedRepo.On("CountWorkflows", mock.Anything, mock.Anything).Return(int64(0), nil)
51+
archivedRepo.On("ListWorkflows", mock.Anything, mock.Anything).Return(v1alpha1.Workflows{}, nil)
52+
archivedRepo.On("HasMoreWorkflows", mock.Anything, mock.Anything).Return(false, nil)
53+
54+
kubeClientSet := fake.NewClientset()
55+
kubeClientSet.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
56+
return true, &authorizationv1.SelfSubjectAccessReview{
57+
Status: authorizationv1.SubjectAccessReviewStatus{Allowed: true},
58+
}, nil
59+
})
60+
61+
wfClientset := v1alpha.NewClientset()
62+
ctx := logging.TestContext(t.Context())
63+
ctx = context.WithValue(context.WithValue(context.WithValue(ctx, auth.WfKey, wfClientset), auth.KubeKey, kubeClientSet), auth.ClaimsKey, &authtypes.Claims{Claims: jwt.Claims{Subject: "my-sub"}})
64+
65+
// An empty instance ID keeps the store from requiring an instance-id label on the fixtures.
66+
instanceIDSvc := instanceid.NewService("")
67+
wfStore, err := store.NewSQLiteStore(instanceIDSvc)
68+
require.NoError(t, err)
69+
for i := range wfs {
70+
require.NoError(t, wfStore.Add(&wfs[i]))
71+
}
72+
73+
namespace := "argo"
74+
server := NewServer(ctx, instanceIDSvc, offloadNodeStatusRepo, archivedRepo, wfClientset, wfStore, nil, workflowtemplate.NewClientStore(), clusterworkflowtemplate.NewClientStore(), nil, &namespace, nil)
75+
return server, ctx, offloadNodeStatusRepo
76+
}
77+
78+
// TestListWorkflows_PassesOnlyPageKeys asserts that the offload query is scoped to the
79+
// workflows on this page, rather than to the whole namespace.
80+
func TestListWorkflows_PassesOnlyPageKeys(t *testing.T) {
81+
wantedA := offloadedWorkflow("offloaded-a", "uid-a", "v1")
82+
wantedB := offloadedWorkflow("offloaded-b", "uid-b", "v2")
83+
server, ctx, offloadNodeStatusRepo := offloadTestServer(t, wantedA, wantedB, offloadedWorkflow("inline", "uid-c", ""))
84+
85+
offloadNodeStatusRepo.On("List", mock.Anything, mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
86+
87+
_, err := server.ListWorkflows(ctx, &workflowpkg.WorkflowListRequest{Namespace: "argo"})
88+
require.NoError(t, err)
89+
90+
offloadNodeStatusRepo.AssertNumberOfCalls(t, "List", 1)
91+
call := offloadNodeStatusRepo.Calls[len(offloadNodeStatusRepo.Calls)-1]
92+
require.Equal(t, "List", call.Method)
93+
assert.Equal(t, "argo", call.Arguments[0])
94+
assert.ElementsMatch(t, []sqldb.UUIDVersion{
95+
{UID: "uid-a", Version: "v1"},
96+
{UID: "uid-b", Version: "v2"},
97+
}, call.Arguments[1], "List must be given exactly the offloaded keys on this page")
98+
}
99+
100+
// TestListWorkflows_SkipsQueryWhenPageHasNoOffload asserts that a page with nothing offloaded
101+
// issues no offload query at all. This is the common case for most users.
102+
func TestListWorkflows_SkipsQueryWhenPageHasNoOffload(t *testing.T) {
103+
server, ctx, offloadNodeStatusRepo := offloadTestServer(t, offloadedWorkflow("inline-a", "uid-a", ""), offloadedWorkflow("inline-b", "uid-b", ""))
104+
105+
_, err := server.ListWorkflows(ctx, &workflowpkg.WorkflowListRequest{Namespace: "argo"})
106+
require.NoError(t, err)
107+
108+
offloadNodeStatusRepo.AssertNotCalled(t, "List")
109+
}

server/workflow/workflow_server_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ func getWorkflowServer(t *testing.T) (workflowpkg.WorkflowServiceServer, context
598598

599599
offloadNodeStatusRepo := &mocks.OffloadNodeStatusRepo{}
600600
offloadNodeStatusRepo.On("IsEnabled", mock.Anything).Return(true)
601-
offloadNodeStatusRepo.On("List", mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
601+
offloadNodeStatusRepo.On("List", mock.Anything, mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
602602

603603
archivedRepo := &mocks.WorkflowArchive{}
604604

@@ -1501,7 +1501,7 @@ func getWorkflowServerWithArtifacts(t *testing.T, template runtime.Object, defau
15011501

15021502
offloadNodeStatusRepo := &mocks.OffloadNodeStatusRepo{}
15031503
offloadNodeStatusRepo.On("IsEnabled", mock.Anything).Return(true)
1504-
offloadNodeStatusRepo.On("List", mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
1504+
offloadNodeStatusRepo.On("List", mock.Anything, mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
15051505

15061506
archivedRepo := &mocks.WorkflowArchive{}
15071507

server/workflowarchive/archived_workflow_server_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func Test_archivedWorkflowServer(t *testing.T) {
3535
wfClient := &argofake.Clientset{}
3636
offloadNodeStatusRepo := &mocks.OffloadNodeStatusRepo{}
3737
offloadNodeStatusRepo.On("IsEnabled", mock.Anything).Return(true)
38-
offloadNodeStatusRepo.On("List", mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
38+
offloadNodeStatusRepo.On("List", mock.Anything, mock.Anything).Return(map[sqldb.UUIDVersion]v1alpha1.Nodes{}, nil)
3939
w := NewWorkflowArchiveServer(repo, offloadNodeStatusRepo, nil)
4040
allowed := true
4141
kubeClient.AddReactor("create", "selfsubjectaccessreviews", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {

0 commit comments

Comments
 (0)