Skip to content

Commit 8424729

Browse files
authored
fix: order Trino pool configuration transitions within each authority term (#1211)
1 parent efd44bd commit 8424729

4 files changed

Lines changed: 417 additions & 31 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
//go:build kubernetes
2+
3+
package controlplane
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"testing"
10+
11+
"github.com/posthog/duckgres/controlplane/trinogateway"
12+
)
13+
14+
type configureReplayEntry struct {
15+
payload string
16+
state trinogateway.PoolState
17+
}
18+
19+
type configureReplayGateway struct {
20+
*fakePoolGateway
21+
state trinogateway.PoolState
22+
journal map[string]configureReplayEntry
23+
requests []trinogateway.ConfigurePoolRequest
24+
loseResponse bool
25+
reject bool
26+
delayRequest bool
27+
corruptReply bool
28+
staleEpoch bool
29+
requestError error
30+
}
31+
32+
func (g *configureReplayGateway) ConfigurePool(_ context.Context, poolID string, request trinogateway.ConfigurePoolRequest) (trinogateway.PoolState, error) {
33+
g.requests = append(g.requests, request)
34+
if g.requestError != nil {
35+
return trinogateway.PoolState{}, g.requestError
36+
}
37+
if g.staleEpoch {
38+
return trinogateway.PoolState{}, trinogateway.ErrStaleEpoch
39+
}
40+
if g.delayRequest {
41+
g.delayRequest = false
42+
return trinogateway.PoolState{}, errors.New("request timed out before commit")
43+
}
44+
if g.reject {
45+
return trinogateway.PoolState{}, &trinogateway.Error{Status: 400, Code: "POOL_VALIDATION"}
46+
}
47+
key := request.OperationID + "/" + request.StepID
48+
payload := fakeRequestPayload(request)
49+
if entry, ok := g.journal[key]; ok {
50+
if entry.payload != payload || entry.state.ControllerEpoch != request.ControllerEpoch {
51+
return trinogateway.PoolState{}, fmt.Errorf("%w: configure payload or epoch changed", trinogateway.ErrIntentChanged)
52+
}
53+
// The Gateway returns the recorded result without applying it again.
54+
state := entry.state
55+
state.Replayed = true
56+
return state, nil
57+
}
58+
g.state = trinogateway.PoolState{
59+
PoolID: poolID, ControllerEpoch: request.ControllerEpoch,
60+
APIMode: request.APIMode, MinServing: request.MinServing,
61+
DesiredMembers: request.DesiredMembers, MaxSurge: request.MaxSurge,
62+
MaxRepair: request.MaxRepair, DesiredRevision: request.DesiredRevision,
63+
TenantAdmissionEnabled: request.TenantAdmissionEnabled,
64+
}
65+
g.journal[key] = configureReplayEntry{payload: payload, state: g.state}
66+
if g.corruptReply {
67+
return trinogateway.PoolState{}, nil
68+
}
69+
if g.loseResponse {
70+
g.loseResponse = false
71+
return trinogateway.PoolState{}, errors.New("response lost after commit")
72+
}
73+
return g.state, nil
74+
}
75+
76+
func TestPoolConfigureRollbackAppliesOriginalReleaseAgain(t *testing.T) {
77+
harness := newOperatorHarness(t)
78+
gateway := &configureReplayGateway{
79+
fakePoolGateway: harness.gateway,
80+
journal: map[string]configureReplayEntry{},
81+
}
82+
harness.operator.gateway = gateway
83+
ctx := context.Background()
84+
if err := harness.operator.ensureAuthority(ctx); err != nil {
85+
t.Fatal(err)
86+
}
87+
initialEpoch := harness.operator.lease.Epoch
88+
for index, release := range []string{"release-a", "release-b", "release-a", "release-b", "release-a"} {
89+
harness.operator.config.Blueprint.ReleaseID = release
90+
harness.operator.config.Spec.DesiredReleaseID = release
91+
harness.operator.config.Spec.DesiredBlueprintDigest = harness.operator.config.Blueprint.Digest()
92+
if err := harness.operator.configureGatewayPool(ctx); err != nil {
93+
t.Fatalf("transition %d: %v", index, err)
94+
}
95+
if gateway.state.DesiredRevision != release {
96+
t.Fatalf("transition %d: Gateway still wants %q, expected %q", index, gateway.state.DesiredRevision, release)
97+
}
98+
if harness.operator.lease.Epoch != initialEpoch {
99+
t.Fatalf("transition %d changed the authority epoch", index)
100+
}
101+
}
102+
if len(gateway.journal) != 5 {
103+
t.Fatalf("recorded %d transitions, want 5", len(gateway.journal))
104+
}
105+
}
106+
107+
func configureReplayHarness(t *testing.T) (*operatorHarness, *configureReplayGateway) {
108+
t.Helper()
109+
harness := newOperatorHarness(t)
110+
gateway := &configureReplayGateway{fakePoolGateway: harness.gateway, journal: map[string]configureReplayEntry{}}
111+
harness.operator.gateway = gateway
112+
if err := harness.operator.ensureAuthority(context.Background()); err != nil {
113+
t.Fatal(err)
114+
}
115+
return harness, gateway
116+
}
117+
118+
func TestPoolConfigureReusesUnchangedRequest(t *testing.T) {
119+
harness, gateway := configureReplayHarness(t)
120+
for range 3 {
121+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
122+
t.Fatal(err)
123+
}
124+
}
125+
if len(gateway.journal) != 1 || gateway.requests[0] != gateway.requests[2] {
126+
t.Fatal("unchanged settings created a new operation")
127+
}
128+
}
129+
130+
func TestPoolConfigureChangesAdmissionSetting(t *testing.T) {
131+
harness, gateway := configureReplayHarness(t)
132+
for _, enabled := range []bool{false, true, false} {
133+
harness.operator.config.Pool.TenantAdmission = enabled
134+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
135+
t.Fatal(err)
136+
}
137+
if gateway.state.TenantAdmissionEnabled != enabled {
138+
t.Fatalf("tenant admission = %t, want %t", gateway.state.TenantAdmissionEnabled, enabled)
139+
}
140+
}
141+
}
142+
143+
func TestPoolConfigureSettlesUnknownBeforeApplyingNewDesired(t *testing.T) {
144+
harness, gateway := configureReplayHarness(t)
145+
gateway.loseResponse = true
146+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
147+
t.Fatal("lost response must stop reconciliation")
148+
}
149+
original := gateway.requests[0]
150+
harness.operator.config.Spec.DesiredReleaseID = "release-next"
151+
harness.operator.config.Spec.DesiredBlueprintDigest = "next-digest"
152+
if err := harness.operator.configureGatewayPool(context.Background()); !errors.Is(err, errTrinoPoolBackoff) {
153+
t.Fatalf("settling obsolete configuration must defer lifecycle work: %v", err)
154+
}
155+
if gateway.requests[1] != original {
156+
t.Fatal("unknown operation was not retried with its exact original payload")
157+
}
158+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
159+
t.Fatal(err)
160+
}
161+
if gateway.state.DesiredRevision != "release-next" || len(gateway.journal) != 2 {
162+
t.Fatalf("new desired configuration was not applied: %+v", gateway.state)
163+
}
164+
}
165+
166+
func TestPoolConfigureNewEpochDiscardsOldPendingAttempt(t *testing.T) {
167+
harness, gateway := configureReplayHarness(t)
168+
gateway.loseResponse = true
169+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
170+
t.Fatal("lost response must stop reconciliation")
171+
}
172+
original := gateway.requests[0]
173+
harness.operator.lease.Epoch = 0
174+
if err := harness.operator.ensureAuthority(context.Background()); err != nil {
175+
t.Fatal(err)
176+
}
177+
harness.operator.config.Spec.DesiredReleaseID = "release-next"
178+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
179+
t.Fatal(err)
180+
}
181+
latest := gateway.requests[1]
182+
if latest.ControllerEpoch <= original.ControllerEpoch || latest.StepID == original.StepID || latest.DesiredRevision != "release-next" {
183+
t.Fatalf("new authority reused old pending configuration: %+v", latest)
184+
}
185+
}
186+
187+
func TestPoolConfigureCorrectedRejectedSettingsCanProceed(t *testing.T) {
188+
harness, gateway := configureReplayHarness(t)
189+
gateway.reject = true
190+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
191+
t.Fatal("rejection must stop reconciliation")
192+
}
193+
gateway.reject = false
194+
harness.operator.config.Pool.TenantAdmission = true
195+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
196+
t.Fatal(err)
197+
}
198+
if !gateway.state.TenantAdmissionEnabled {
199+
t.Fatal("corrected configuration was not applied")
200+
}
201+
}
202+
203+
func TestPoolConfigureDelayedRequestCannotOverwriteLaterSettings(t *testing.T) {
204+
harness, gateway := configureReplayHarness(t)
205+
gateway.delayRequest = true
206+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
207+
t.Fatal("timeout must stop reconciliation")
208+
}
209+
delayed := gateway.requests[0]
210+
harness.operator.config.Spec.DesiredReleaseID = "release-next"
211+
if err := harness.operator.configureGatewayPool(context.Background()); !errors.Is(err, errTrinoPoolBackoff) {
212+
t.Fatalf("old request must settle before new configuration: %v", err)
213+
}
214+
if gateway.requests[1] != delayed {
215+
t.Fatal("delayed request was replaced instead of settled")
216+
}
217+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
218+
t.Fatal(err)
219+
}
220+
if _, err := gateway.ConfigurePool(context.Background(), "cell-001", delayed); err != nil {
221+
t.Fatal(err)
222+
}
223+
if gateway.state.DesiredRevision != "release-next" {
224+
t.Fatal("late request overwrote the current configuration")
225+
}
226+
}
227+
228+
func TestPoolConfigureRejectionAfterUnknownDoesNotReleasePendingAttempt(t *testing.T) {
229+
harness, gateway := configureReplayHarness(t)
230+
gateway.delayRequest = true
231+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
232+
t.Fatal("timeout must stop reconciliation")
233+
}
234+
original := gateway.requests[0]
235+
harness.operator.config.Spec.DesiredReleaseID = "release-next"
236+
gateway.reject = true
237+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
238+
t.Fatal("rejection must stop reconciliation")
239+
}
240+
gateway.reject = false
241+
if err := harness.operator.configureGatewayPool(context.Background()); !errors.Is(err, errTrinoPoolBackoff) {
242+
t.Fatalf("unknown old request must still settle first: %v", err)
243+
}
244+
if gateway.requests[2] != original {
245+
t.Fatal("a later refusal discarded the earlier unknown request")
246+
}
247+
}
248+
249+
func TestPoolConfigureRejectsMismatchedSuccessResponse(t *testing.T) {
250+
harness, gateway := configureReplayHarness(t)
251+
gateway.corruptReply = true
252+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
253+
t.Fatal("mismatched successful response must stop reconciliation")
254+
}
255+
original := gateway.requests[0]
256+
gateway.corruptReply = false
257+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
258+
t.Fatal(err)
259+
}
260+
if gateway.requests[1] != original {
261+
t.Fatal("invalid response changed the pending request")
262+
}
263+
}
264+
265+
func TestPoolConfigureStaleEpochEndsAuthority(t *testing.T) {
266+
harness, gateway := configureReplayHarness(t)
267+
gateway.staleEpoch = true
268+
if err := harness.operator.configureGatewayPool(context.Background()); !errors.Is(err, trinogateway.ErrStaleEpoch) {
269+
t.Fatalf("stale epoch error = %v", err)
270+
}
271+
if !harness.operator.fenced || harness.operator.lease.Epoch != 0 {
272+
t.Fatal("stale configuration attempt did not end the authority term")
273+
}
274+
}
275+
276+
func TestPoolConfigureHTTPTimeoutAndProxyErrorsRemainUnknown(t *testing.T) {
277+
for _, status := range []int{400, 403, 408, 429, 502, 504} {
278+
t.Run(fmt.Sprint(status), func(t *testing.T) {
279+
harness, gateway := configureReplayHarness(t)
280+
gateway.requestError = &trinogateway.Error{Status: status}
281+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
282+
t.Fatal("unclassified HTTP error must stop reconciliation")
283+
}
284+
original := gateway.requests[0]
285+
gateway.requestError = nil
286+
harness.operator.config.Spec.DesiredReleaseID = "release-next"
287+
if err := harness.operator.configureGatewayPool(context.Background()); !errors.Is(err, errTrinoPoolBackoff) {
288+
t.Fatalf("ambiguous HTTP outcome must settle first: %v", err)
289+
}
290+
if gateway.requests[1] != original {
291+
t.Fatal("unclassified HTTP error discarded the pending request")
292+
}
293+
})
294+
}
295+
}
296+
297+
func TestPoolConfigureRoutingIdentityChangeDoesNotRedirectPendingRequest(t *testing.T) {
298+
harness, gateway := configureReplayHarness(t)
299+
gateway.delayRequest = true
300+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
301+
t.Fatal("timeout must stop reconciliation")
302+
}
303+
original := gateway.requests[0]
304+
originalGroup := harness.operator.config.RoutingGroup
305+
harness.operator.config.RoutingGroup = "different-pool"
306+
if err := harness.operator.configureGatewayPool(context.Background()); err == nil {
307+
t.Fatal("routing identity change must stop reconciliation")
308+
}
309+
if len(gateway.requests) != 1 {
310+
t.Fatal("pending request was sent against changed routing identity")
311+
}
312+
harness.operator.config.RoutingGroup = originalGroup
313+
if err := harness.operator.configureGatewayPool(context.Background()); err != nil {
314+
t.Fatal(err)
315+
}
316+
if gateway.requests[1] != original || gateway.state.PoolID != originalGroup {
317+
t.Fatal("restoring routing identity did not settle the original request")
318+
}
319+
}

0 commit comments

Comments
 (0)