v3.1.0 d676137

store_claim_test.go RawHistoryFolder

1package appstate_test
2
3import (
4	"context"
5	"sync"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/gordon/internal/adapters/out/appstate"
13	"github.com/bnema/gordon/internal/domain"
14)
15
16// knownApp gives the app a live identity so keyed mutations are allowed.
17func knownApp(t *testing.T, store interface {
18	SaveOwnership(context.Context, domain.AppOwnership) error
19}, app string) {
20	t.Helper()
21	require.NoError(t, store.SaveOwnership(context.Background(), domain.AppOwnership{App: app}))
22}
23
24func deployClaim(app, key, revision string) domain.AppOperation {
25	return domain.AppOperation{
26		Op:        key,
27		Kind:      "deploy",
28		App:       app,
29		StartedAt: time.Now().UTC(),
30		Request:   domain.AppOperationRequestFor("deploy", app, revision, ""),
31	}
32}
33
34// TestStore_ClaimOperationCreatesInFlightJournal proves an absent key is
35// persisted as an in-flight claim so no effect can run twice.
36func TestStore_ClaimOperationCreatesInFlightJournal(t *testing.T) {
37	ctx := context.Background()
38	store := newTestStore(t)
39	knownApp(t, store, "blog")
40
41	candidate := deployClaim("blog", "key-1", "rev-1")
42	existing, claimed, err := store.ClaimOperation(ctx, candidate)
43	require.NoError(t, err)
44	require.True(t, claimed)
45	assert.Equal(t, "key-1", existing.Op)
46	assert.False(t, existing.Terminal(), "a fresh claim is not terminal")
47
48	stored, err := store.LoadOperation(ctx, "blog", "key-1")
49	require.NoError(t, err)
50	assert.Equal(t, candidate.Request, stored.Request)
51	assert.False(t, stored.Terminal())
52}
53
54// TestStore_ClaimOperationReplaysSameRequest proves a repeat of one
55// request returns the stored journal instead of claiming again.
56func TestStore_ClaimOperationReplaysSameRequest(t *testing.T) {
57	ctx := context.Background()
58	store := newTestStore(t)
59	knownApp(t, store, "blog")
60
61	require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
62		Op: "key-1", Kind: "deploy", App: "blog",
63		Request:   domain.AppOperationRequestFor("deploy", "blog", "rev-1", ""),
64		Outcome:   domain.AppOutcomeSuccess,
65		StartedAt: time.Now().UTC(),
66	}))
67
68	existing, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
69	require.NoError(t, err)
70	assert.False(t, claimed)
71	assert.Equal(t, domain.AppOutcomeSuccess, existing.Outcome)
72}
73
74// TestStore_ClaimOperationConflictsOnDifferentRequest proves one key can
75// never answer two different requests.
76func TestStore_ClaimOperationConflictsOnDifferentRequest(t *testing.T) {
77	ctx := context.Background()
78	store := newTestStore(t)
79	knownApp(t, store, "blog")
80
81	_, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
82	require.NoError(t, err)
83	require.True(t, claimed)
84
85	_, _, err = store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-2"))
86	require.ErrorIs(t, err, domain.ErrAppStateConflict)
87
88	// A different kind with the same key is a different request too.
89	stop := domain.AppOperation{
90		Op: "key-1", Kind: "stop", App: "blog", StartedAt: time.Now().UTC(),
91		Request: domain.AppOperationRequestFor("stop", "blog", "", ""),
92	}
93	_, _, err = store.ClaimOperation(ctx, stop)
94	require.ErrorIs(t, err, domain.ErrAppStateConflict)
95}
96
97// TestStore_ClaimOperationRefusesUnknownAppWithoutState proves a keyed
98// mutation of an unknown name creates no durable state at all.
99func TestStore_ClaimOperationRefusesUnknownAppWithoutState(t *testing.T) {
100	ctx := context.Background()
101	store := newTestStore(t)
102
103	_, _, err := store.ClaimOperation(ctx, deployClaim("ghost", "key-1", "rev-1"))
104	require.ErrorIs(t, err, domain.ErrAppNotFound)
105
106	apps, err := store.ListApps(ctx)
107	require.NoError(t, err)
108	assert.NotContains(t, apps, "ghost", "a refused claim must not create an app entry")
109
110	_, err = store.LoadOperation(ctx, "ghost", "key-1")
111	require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
112}
113
114// TestStore_ClaimOperationIsAtomicUnderConcurrency proves two concurrent
115// claims of one key produce exactly one winner.
116func TestStore_ClaimOperationIsAtomicUnderConcurrency(t *testing.T) {
117	ctx := context.Background()
118	store := newTestStore(t)
119	knownApp(t, store, "blog")
120
121	const racers = 8
122	var (
123		wg      sync.WaitGroup
124		mu      sync.Mutex
125		winners int
126	)
127	start := make(chan struct{})
128	for range racers {
129		wg.Add(1)
130		go func() {
131			defer wg.Done()
132			<-start
133			_, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
134			mu.Lock()
135			defer mu.Unlock()
136			if err != nil {
137				t.Errorf("claim: %v", err)
138				return
139			}
140			if claimed {
141				winners++
142			}
143		}()
144	}
145	close(start)
146	wg.Wait()
147	assert.Equal(t, 1, winners, "exactly one claim may win")
148}
149
150// TestStore_ClaimOperationReplaysAfterReopen proves a claimed operation
151// survives a daemon restart: the reopened store replays the stored journal
152// instead of claiming the key again.
153func TestStore_ClaimOperationReplaysAfterReopen(t *testing.T) {
154	ctx := context.Background()
155	dir := t.TempDir()
156	store, err := appstate.NewStore(dir, testLogger())
157	require.NoError(t, err)
158	require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog"}))
159
160	_, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
161	require.NoError(t, err)
162	require.True(t, claimed)
163	op := deployClaim("blog", "key-1", "rev-1")
164	op.Outcome = domain.AppOutcomeSuccess
165	require.NoError(t, store.SaveOperation(ctx, op))
166	require.NoError(t, store.Close())
167
168	reopened, err := appstate.NewStore(dir, testLogger())
169	require.NoError(t, err)
170	t.Cleanup(func() { assert.NoError(t, reopened.Close()) })
171
172	existing, claimed, err := reopened.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
173	require.NoError(t, err)
174	assert.False(t, claimed, "a reopened store replays the persisted claim")
175	assert.Equal(t, domain.AppOutcomeSuccess, existing.Outcome)
176}
177
178// TestStore_ClaimOperationSurvivesRetire proves an operation journal
179// stays reachable after removal while a new key against the freed name
180// is refused.
181func TestStore_ClaimOperationSurvivesRetire(t *testing.T) {
182	ctx := context.Background()
183	store := newTestStore(t)
184	knownApp(t, store, "blog")
185
186	remove := domain.AppOperation{
187		Op: "remove-1", Kind: "remove", App: "blog", StartedAt: time.Now().UTC(),
188		Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
189	}
190	_, claimed, err := store.ClaimOperation(ctx, remove)
191	require.NoError(t, err)
192	require.True(t, claimed)
193	remove.Outcome = domain.AppOutcomeSuccess
194	remove.Steps = []domain.AppOperationStep{{ID: "service.web.remove", State: domain.AppStepSucceeded}}
195	require.NoError(t, store.SaveOperation(ctx, remove))
196	require.NoError(t, store.RetireApp(ctx, "blog"))
197
198	replayed, claimed, err := store.ClaimOperation(ctx, domain.AppOperation{
199		Op: "remove-1", Kind: "remove", App: "blog",
200		Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
201	})
202	require.NoError(t, err)
203	assert.False(t, claimed, "a repeated remove replays its stored result")
204	assert.Equal(t, domain.AppOutcomeSuccess, replayed.Outcome)
205
206	_, _, err = store.ClaimOperation(ctx, domain.AppOperation{
207		Op: "remove-2", Kind: "remove", App: "blog",
208		Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
209	})
210	require.ErrorIs(t, err, domain.ErrAppNotFound, "a new key must not resurrect a retired name")
211}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close