Skip to content

Commit 706b856

Browse files
JamyDevclaude
andcommitted
feat(platform): add lifecycle.Component and lifecycle.Group
Introduce `platform/lifecycle` with two exports: - `Component` interface: `Start(ctx) error`, `Stop(ctx) error` — the one lifecycle abstraction every runnable subsystem implements. - `Group`: runs an ordered list of Components as one Component. Start proceeds in order with rollback on partial failure (no half-started state). Stop proceeds in reverse order, joining all errors so none is swallowed. Pure addition — no existing code changes. Ref: doc/rfc/submitqueue/modular-queue-wiring.md (Step 1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 325ee00 commit 706b856

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

platform/lifecycle/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["lifecycle.go"],
6+
importpath = "github.com/uber/submitqueue/platform/lifecycle",
7+
visibility = ["//visibility:public"],
8+
)
9+
10+
go_test(
11+
name = "go_default_test",
12+
srcs = ["lifecycle_test.go"],
13+
embed = [":go_default_library"],
14+
deps = [
15+
"@com_github_stretchr_testify//assert:go_default_library",
16+
"@com_github_stretchr_testify//require:go_default_library",
17+
],
18+
)

platform/lifecycle/lifecycle.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package lifecycle provides the Component interface and Group type for
16+
// managing ordered start/stop lifecycles. Every runnable subsystem (consumer,
17+
// publisher, server) implements Component; Group composes them into a single
18+
// Component with deterministic ordering and rollback on partial failure.
19+
package lifecycle
20+
21+
import (
22+
"context"
23+
"errors"
24+
"fmt"
25+
)
26+
27+
// Component is anything with a lifecycle. Construct returns one; hosts drive it.
28+
type Component interface {
29+
// Start initializes and starts the component. The context governs the
30+
// start-up phase (e.g. connecting, subscribing); long-running work may
31+
// outlive the context and must be terminated by calling Stop.
32+
Start(ctx context.Context) error
33+
34+
// Stop gracefully shuts down the component. The context provides a
35+
// deadline for the shutdown; implementations should respect it and
36+
// return promptly when the context is cancelled.
37+
Stop(ctx context.Context) error
38+
}
39+
40+
// Group runs an ordered list of Components as one Component.
41+
//
42+
// - Start: members in order; if member i fails to start, members i-1…0 are
43+
// stopped in reverse and the error is returned — no half-started state.
44+
// - Stop: members in REVERSE order (work-acceptors drain before the
45+
// connections under them close); errors joined, none swallowed.
46+
type Group struct {
47+
members []Component
48+
}
49+
50+
// NewGroup creates a Group from the given components. Nil members are silently
51+
// skipped so callers can pass optional components without nil-checking.
52+
func NewGroup(members ...Component) *Group {
53+
filtered := make([]Component, 0, len(members))
54+
for _, m := range members {
55+
if m != nil {
56+
filtered = append(filtered, m)
57+
}
58+
}
59+
return &Group{members: filtered}
60+
}
61+
62+
// Start starts all members in order. If any member fails to start, all
63+
// previously started members are stopped in reverse order and the original
64+
// start error is returned. The stop errors from rollback, if any, are joined
65+
// with the start error.
66+
func (g *Group) Start(ctx context.Context) error {
67+
for i, m := range g.members {
68+
if err := m.Start(ctx); err != nil {
69+
// Rollback: stop members i-1…0 in reverse order.
70+
rollbackErr := g.stopRange(ctx, i-1)
71+
return errors.Join(fmt.Errorf("component %d failed to start: %w", i, err), rollbackErr)
72+
}
73+
}
74+
return nil
75+
}
76+
77+
// Stop stops all members in reverse order. All stop errors are joined so
78+
// none is swallowed; a single member's failure does not prevent the others
79+
// from being stopped.
80+
func (g *Group) Stop(ctx context.Context) error {
81+
return g.stopRange(ctx, len(g.members)-1)
82+
}
83+
84+
// stopRange stops members from index hi down to 0 (inclusive), collecting
85+
// all errors. A negative hi is a no-op.
86+
func (g *Group) stopRange(ctx context.Context, hi int) error {
87+
var errs []error
88+
for i := hi; i >= 0; i-- {
89+
if err := g.members[i].Stop(ctx); err != nil {
90+
errs = append(errs, fmt.Errorf("component %d failed to stop: %w", i, err))
91+
}
92+
}
93+
return errors.Join(errs...)
94+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package lifecycle
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
// spy records the order of Start/Stop calls and can be configured to fail.
27+
type spy struct {
28+
name string
29+
startErr error
30+
stopErr error
31+
log *[]string
32+
}
33+
34+
func (s *spy) Start(_ context.Context) error {
35+
*s.log = append(*s.log, "start:"+s.name)
36+
return s.startErr
37+
}
38+
39+
func (s *spy) Stop(_ context.Context) error {
40+
*s.log = append(*s.log, "stop:"+s.name)
41+
return s.stopErr
42+
}
43+
44+
func TestGroup_StartStop_HappyPath(t *testing.T) {
45+
var log []string
46+
a := &spy{name: "a", log: &log}
47+
b := &spy{name: "b", log: &log}
48+
c := &spy{name: "c", log: &log}
49+
50+
g := NewGroup(a, b, c)
51+
52+
require.NoError(t, g.Start(context.Background()))
53+
assert.Equal(t, []string{"start:a", "start:b", "start:c"}, log)
54+
55+
log = nil
56+
require.NoError(t, g.Stop(context.Background()))
57+
assert.Equal(t, []string{"stop:c", "stop:b", "stop:a"}, log)
58+
}
59+
60+
func TestGroup_StartRollback_OnFailure(t *testing.T) {
61+
var log []string
62+
a := &spy{name: "a", log: &log}
63+
b := &spy{name: "b", startErr: fmt.Errorf("b broke"), log: &log}
64+
c := &spy{name: "c", log: &log}
65+
66+
g := NewGroup(a, b, c)
67+
68+
err := g.Start(context.Background())
69+
require.Error(t, err)
70+
assert.Contains(t, err.Error(), "b broke")
71+
72+
// a was started and then rolled back; b failed; c was never started
73+
assert.Equal(t, []string{"start:a", "start:b", "stop:a"}, log)
74+
}
75+
76+
func TestGroup_StartRollback_FirstMemberFails(t *testing.T) {
77+
var log []string
78+
a := &spy{name: "a", startErr: fmt.Errorf("a broke"), log: &log}
79+
b := &spy{name: "b", log: &log}
80+
81+
g := NewGroup(a, b)
82+
83+
err := g.Start(context.Background())
84+
require.Error(t, err)
85+
assert.Contains(t, err.Error(), "a broke")
86+
87+
// Nothing to roll back — a failed on start, b never started
88+
assert.Equal(t, []string{"start:a"}, log)
89+
}
90+
91+
func TestGroup_StartRollback_JoinsStopErrors(t *testing.T) {
92+
var log []string
93+
a := &spy{name: "a", stopErr: fmt.Errorf("a stop failed"), log: &log}
94+
b := &spy{name: "b", log: &log}
95+
c := &spy{name: "c", startErr: fmt.Errorf("c broke"), log: &log}
96+
97+
g := NewGroup(a, b, c)
98+
99+
err := g.Start(context.Background())
100+
require.Error(t, err)
101+
assert.Contains(t, err.Error(), "c broke")
102+
assert.Contains(t, err.Error(), "a stop failed")
103+
104+
// a and b started, c failed, then b and a rolled back in reverse
105+
assert.Equal(t, []string{"start:a", "start:b", "start:c", "stop:b", "stop:a"}, log)
106+
}
107+
108+
func TestGroup_Stop_CollectsAllErrors(t *testing.T) {
109+
var log []string
110+
a := &spy{name: "a", stopErr: fmt.Errorf("a stop failed"), log: &log}
111+
b := &spy{name: "b", stopErr: fmt.Errorf("b stop failed"), log: &log}
112+
c := &spy{name: "c", log: &log}
113+
114+
g := NewGroup(a, b, c)
115+
require.NoError(t, g.Start(context.Background()))
116+
117+
log = nil
118+
err := g.Stop(context.Background())
119+
require.Error(t, err)
120+
assert.Contains(t, err.Error(), "a stop failed")
121+
assert.Contains(t, err.Error(), "b stop failed")
122+
123+
// All three stopped in reverse despite errors
124+
assert.Equal(t, []string{"stop:c", "stop:b", "stop:a"}, log)
125+
}
126+
127+
func TestGroup_NilMembers_Skipped(t *testing.T) {
128+
var log []string
129+
a := &spy{name: "a", log: &log}
130+
131+
g := NewGroup(nil, a, nil)
132+
133+
require.NoError(t, g.Start(context.Background()))
134+
assert.Equal(t, []string{"start:a"}, log)
135+
136+
log = nil
137+
require.NoError(t, g.Stop(context.Background()))
138+
assert.Equal(t, []string{"stop:a"}, log)
139+
}
140+
141+
func TestGroup_Empty(t *testing.T) {
142+
g := NewGroup()
143+
require.NoError(t, g.Start(context.Background()))
144+
require.NoError(t, g.Stop(context.Background()))
145+
}
146+
147+
func TestGroup_Nested(t *testing.T) {
148+
var log []string
149+
a := &spy{name: "a", log: &log}
150+
b := &spy{name: "b", log: &log}
151+
c := &spy{name: "c", log: &log}
152+
d := &spy{name: "d", log: &log}
153+
154+
inner := NewGroup(b, c)
155+
outer := NewGroup(a, inner, d)
156+
157+
require.NoError(t, outer.Start(context.Background()))
158+
assert.Equal(t, []string{"start:a", "start:b", "start:c", "start:d"}, log)
159+
160+
log = nil
161+
require.NoError(t, outer.Stop(context.Background()))
162+
assert.Equal(t, []string{"stop:d", "stop:c", "stop:b", "stop:a"}, log)
163+
}
164+
165+
func TestGroup_Nested_RollbackOnInnerFailure(t *testing.T) {
166+
var log []string
167+
a := &spy{name: "a", log: &log}
168+
b := &spy{name: "b", log: &log}
169+
c := &spy{name: "c", startErr: fmt.Errorf("c broke"), log: &log}
170+
d := &spy{name: "d", log: &log}
171+
172+
inner := NewGroup(b, c)
173+
outer := NewGroup(a, inner, d)
174+
175+
err := outer.Start(context.Background())
176+
require.Error(t, err)
177+
assert.Contains(t, err.Error(), "c broke")
178+
179+
// a started, inner started b then c failed, inner rolled back b,
180+
// then outer rolled back a. d never started.
181+
assert.Equal(t, []string{"start:a", "start:b", "start:c", "stop:b", "stop:a"}, log)
182+
}

0 commit comments

Comments
 (0)