Skip to content

Commit 835adc5

Browse files
jakubnoe2b-bot[bot]
authored andcommitted
fix(db): make snapshot template creation replay-safe
GitOrigin-RevId: 819d2d3485be42b9b75759a8e867e155663fd982
1 parent 6bddba0 commit 835adc5

3 files changed

Lines changed: 274 additions & 14 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package snapshots
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
8+
"github.com/google/uuid"
9+
"github.com/jackc/pgerrcode"
10+
"github.com/jackc/pgx/v5"
11+
"github.com/jackc/pgx/v5/pgconn"
12+
"github.com/jackc/pgx/v5/pgxpool"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/e2b-dev/infra/packages/db/pkg/retry"
17+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
18+
"github.com/e2b-dev/infra/packages/db/pkg/types"
19+
"github.com/e2b-dev/infra/packages/db/queries"
20+
)
21+
22+
// snapshotTemplateFixture mirrors what CreateSnapshotTemplate passes once
23+
// UpsertSnapshot has already created the build.
24+
func snapshotTemplateFixture(t *testing.T, client *testutils.Database) queries.CreateSnapshotTemplateEnvParams {
25+
t.Helper()
26+
27+
ctx := t.Context()
28+
teamID := testutils.CreateTestTeam(t, client)
29+
baseTemplateID := testutils.CreateTestTemplate(t, client, teamID)
30+
buildID := testutils.CreateTestBuild(t, ctx, client, baseTemplateID, "uploaded")
31+
originNodeID := "node-1"
32+
33+
return queries.CreateSnapshotTemplateEnvParams{
34+
SnapshotID: "snapshot-tmpl-" + uuid.New().String(),
35+
TeamID: teamID,
36+
SandboxID: "sandbox-" + uuid.New().String(),
37+
OriginNodeID: &originNodeID,
38+
BuildID: &buildID,
39+
Tag: "default",
40+
}
41+
}
42+
43+
func countRows(t *testing.T, ctx context.Context, client *testutils.Database, query, envID string) int {
44+
t.Helper()
45+
46+
var count int
47+
48+
err := client.SqlcClient.TestsRawSQLQuery(ctx, query,
49+
func(rows pgx.Rows) error {
50+
rows.Next()
51+
52+
return rows.Scan(&count)
53+
},
54+
envID,
55+
)
56+
require.NoError(t, err)
57+
58+
return count
59+
}
60+
61+
func countEnvs(t *testing.T, ctx context.Context, client *testutils.Database, envID string) int {
62+
t.Helper()
63+
64+
return countRows(t, ctx, client, "SELECT count(*) FROM public.envs WHERE id = $1", envID)
65+
}
66+
67+
func countSnapshotTemplates(t *testing.T, ctx context.Context, client *testutils.Database, envID string) int {
68+
t.Helper()
69+
70+
return countRows(t, ctx, client,
71+
"SELECT count(*) FROM public.snapshot_templates WHERE env_id = $1", envID)
72+
}
73+
74+
func countBuildAssignments(t *testing.T, ctx context.Context, client *testutils.Database, envID string) int {
75+
t.Helper()
76+
77+
return countRows(t, ctx, client,
78+
"SELECT count(*) FROM public.env_build_assignments WHERE env_id = $1", envID)
79+
}
80+
81+
func TestCreateSnapshotTemplateEnv_CreatesTemplate(t *testing.T) {
82+
t.Parallel()
83+
84+
client := testutils.SetupDatabase(t)
85+
ctx := t.Context()
86+
87+
params := snapshotTemplateFixture(t, client)
88+
89+
envID, err := client.SqlcClient.CreateSnapshotTemplateEnv(ctx, params)
90+
require.NoError(t, err)
91+
assert.Equal(t, params.SnapshotID, envID)
92+
93+
var source string
94+
err = client.SqlcClient.TestsRawSQLQuery(ctx,
95+
"SELECT source FROM public.envs WHERE id = $1",
96+
func(rows pgx.Rows) error {
97+
rows.Next()
98+
99+
return rows.Scan(&source)
100+
},
101+
envID,
102+
)
103+
require.NoError(t, err)
104+
assert.Equal(t, "snapshot_template", source)
105+
}
106+
107+
// inDoubtOnce lets the first statement commit for real, then reports the
108+
// connection loss the client would have seen. 57P01 is what a terminated
109+
// backend actually sends, and retry.IsRetriable accepts it, so the pool
110+
// replays the statement with the id the caller already used.
111+
type inDoubtOnce struct {
112+
types.DBTX
113+
114+
fired atomic.Bool
115+
}
116+
117+
func (f *inDoubtOnce) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
118+
row := f.DBTX.QueryRow(ctx, sql, args...)
119+
if f.fired.CompareAndSwap(false, true) {
120+
return inDoubtRow{inner: row}
121+
}
122+
123+
return row
124+
}
125+
126+
type inDoubtRow struct{ inner pgx.Row }
127+
128+
func (r inDoubtRow) Scan(dest ...any) error {
129+
_ = r.inner.Scan(dest...)
130+
131+
return &pgconn.PgError{
132+
Severity: "FATAL",
133+
Code: pgerrcode.AdminShutdown,
134+
Message: "terminating connection due to administrator command",
135+
}
136+
}
137+
138+
// A backend terminated after its commit but before the ack — a failover, or an
139+
// operator ending the session — leaves the pool retrying a statement that has
140+
// already taken effect.
141+
func TestCreateSnapshotTemplateEnv_SurvivesInDoubtCommit(t *testing.T) {
142+
t.Parallel()
143+
144+
client := testutils.SetupDatabase(t)
145+
ctx := t.Context()
146+
147+
raw, err := pgxpool.New(ctx, client.ConnStr())
148+
require.NoError(t, err)
149+
defer raw.Close()
150+
151+
replaying := queries.New(retry.Wrap(&inDoubtOnce{DBTX: raw}, retry.DefaultConfig()))
152+
153+
params := snapshotTemplateFixture(t, client)
154+
155+
envID, err := replaying.CreateSnapshotTemplateEnv(ctx, params)
156+
require.NoError(t, err, "a replayed statement must not fail on its own key")
157+
158+
assert.Equal(t, params.SnapshotID, envID)
159+
assert.Equal(t, 1, countEnvs(t, ctx, client, params.SnapshotID), "replay must not create a second env")
160+
assert.Equal(t, 1, countSnapshotTemplates(t, ctx, client, params.SnapshotID))
161+
assert.Equal(t, 1, countBuildAssignments(t, ctx, client, params.SnapshotID))
162+
}
163+
164+
func TestCreateSnapshotTemplateEnv_ConcurrentCallsCreateOneAssignment(t *testing.T) {
165+
t.Parallel()
166+
167+
client := testutils.SetupDatabase(t)
168+
ctx := t.Context()
169+
170+
params := snapshotTemplateFixture(t, client)
171+
172+
const calls = 2
173+
174+
start := make(chan struct{})
175+
errors := make(chan error, calls)
176+
177+
for range calls {
178+
go func() {
179+
<-start
180+
_, err := client.SqlcClient.CreateSnapshotTemplateEnv(ctx, params)
181+
errors <- err
182+
}()
183+
}
184+
185+
close(start)
186+
187+
for range calls {
188+
require.NoError(t, <-errors)
189+
}
190+
191+
assert.Equal(t, 1, countSnapshotTemplates(t, ctx, client, params.SnapshotID))
192+
assert.Equal(t, 1, countBuildAssignments(t, ctx, client, params.SnapshotID))
193+
}
194+
195+
// The conflict path must never adopt an env this call did not write.
196+
func TestCreateSnapshotTemplateEnv_RefusesAnotherTeamsEnv(t *testing.T) {
197+
t.Parallel()
198+
199+
client := testutils.SetupDatabase(t)
200+
ctx := t.Context()
201+
202+
params := snapshotTemplateFixture(t, client)
203+
204+
// A different team already owns an env with the id we are about to use.
205+
otherTeamID := testutils.CreateTestTeam(t, client)
206+
err := client.SqlcClient.TestsRawSQL(ctx,
207+
`INSERT INTO public.envs (id, public, team_id, updated_at, source)
208+
VALUES ($1, FALSE, $2, NOW(), 'snapshot_template')`,
209+
params.SnapshotID, otherTeamID,
210+
)
211+
require.NoError(t, err)
212+
213+
_, err = client.SqlcClient.CreateSnapshotTemplateEnv(ctx, params)
214+
require.Error(t, err, "must not attach the build to another team's env")
215+
216+
var owner uuid.UUID
217+
err = client.SqlcClient.TestsRawSQLQuery(ctx,
218+
"SELECT team_id FROM public.envs WHERE id = $1",
219+
func(rows pgx.Rows) error {
220+
rows.Next()
221+
222+
return rows.Scan(&owner)
223+
},
224+
params.SnapshotID,
225+
)
226+
require.NoError(t, err)
227+
assert.Equal(t, otherTeamID, owner, "the other team's env must be untouched")
228+
}
229+
230+
// A template deleted deliberately must not return as a side effect of a retry.
231+
func TestCreateSnapshotTemplateEnv_RefusesSoftDeletedEnv(t *testing.T) {
232+
t.Parallel()
233+
234+
client := testutils.SetupDatabase(t)
235+
ctx := t.Context()
236+
237+
params := snapshotTemplateFixture(t, client)
238+
239+
_, err := client.SqlcClient.CreateSnapshotTemplateEnv(ctx, params)
240+
require.NoError(t, err)
241+
242+
err = client.SqlcClient.TestsRawSQL(ctx,
243+
"UPDATE public.envs SET deleted_at = NOW() WHERE id = $1", params.SnapshotID)
244+
require.NoError(t, err)
245+
246+
_, err = client.SqlcClient.CreateSnapshotTemplateEnv(ctx, params)
247+
require.Error(t, err, "a soft-deleted template must not be revived by a replay")
248+
}

packages/db/queries/create_snapshot_template_env.sql.go

Lines changed: 13 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
-- name: CreateSnapshotTemplateEnv :one
22
-- Creates a snapshot_template env entry with source='snapshot_template' and links it to an existing build
33
-- This is used after UpsertSnapshot to create a persistent snapshot template
4+
--
5+
-- The caller generates snapshot_id outside the pool's retry loop, so a replay
6+
-- reuses it. The conflict guards match only a row this statement could have
7+
-- written; anything else returns no row, which fails the dependent inserts.
48
WITH new_env AS (
59
INSERT INTO "public"."envs" (id, public, created_by, team_id, updated_at, source, cluster_id)
610
VALUES (@snapshot_id, FALSE, NULL, @team_id, now(), 'snapshot_template', @cluster_id)
11+
ON CONFLICT (id) DO UPDATE SET updated_at = now()
12+
WHERE envs.team_id = @team_id
13+
AND envs.source = 'snapshot_template'
14+
AND envs.deleted_at IS NULL
715
RETURNING id
816
),
917

@@ -15,16 +23,14 @@ snapshot_template AS (
1523
@origin_node_id,
1624
@build_id
1725
)
26+
ON CONFLICT (env_id) DO NOTHING
27+
RETURNING env_id
1828
),
1929

2030
build_assignment AS (
2131
INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag)
22-
VALUES (
23-
(SELECT id FROM new_env),
24-
@build_id,
25-
@tag
26-
)
27-
RETURNING env_id as snapshot_id
32+
SELECT env_id, @build_id, @tag
33+
FROM snapshot_template
2834
)
2935

30-
SELECT snapshot_id FROM build_assignment;
36+
SELECT id AS snapshot_id FROM new_env;

0 commit comments

Comments
 (0)