|
| 1 | +package server |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "sync/atomic" |
| 7 | + "testing" |
| 8 | + "testing/synctest" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" |
| 12 | + "github.com/launchdarkly/go-server-sdk/v7/testhelpers/ldtestdata" |
| 13 | + "github.com/stretchr/testify/require" |
| 14 | + |
| 15 | + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/build" |
| 16 | + "github.com/e2b-dev/infra/packages/orchestrator/pkg/service" |
| 17 | + "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" |
| 18 | + "github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator" |
| 19 | + "github.com/e2b-dev/infra/packages/shared/pkg/utils" |
| 20 | +) |
| 21 | + |
| 22 | +// harvestFlagClient enables the harvest with a one-second budget, consume off. |
| 23 | +func harvestFlagClient(t *testing.T) *featureflags.Client { |
| 24 | + t.Helper() |
| 25 | + |
| 26 | + td := ldtestdata.DataSource() |
| 27 | + td.Update(td.Flag(featureflags.PauseResumePrefetchHarvestFlag.Key()).VariationForAll(true)) |
| 28 | + td.Update(td.Flag(featureflags.PauseResumePrefetchHarvestTimeoutMsFlag.Key()).ValueForAll(ldvalue.Int(1000))) |
| 29 | + ff, err := featureflags.NewClientWithDatasource(td) |
| 30 | + require.NoError(t, err) |
| 31 | + t.Cleanup(func() { _ = ff.Close(context.WithoutCancel(t.Context())) }) |
| 32 | + |
| 33 | + return ff |
| 34 | +} |
| 35 | + |
| 36 | +// A filesystem-only checkpoint has no memfile to resume, so no harvest is |
| 37 | +// scheduled even with the flag on. |
| 38 | +func TestHarvestCheckpointPrefetchAsync_SkipsFilesystemOnly(t *testing.T) { |
| 39 | + t.Parallel() |
| 40 | + |
| 41 | + s := &Server{info: &service.ServiceInfo{}, featureFlags: harvestFlagClient(t)} |
| 42 | + res := &snapshotResult{rootfsDiff: &build.NoDiff{}} |
| 43 | + s.harvestCheckpointPrefetchAsync(t.Context(), testHarvestSandbox(), res, &orchestrator.SandboxCheckpointRequest{BuildId: "build-1", FilesystemOnly: true}) |
| 44 | + require.Zero(t, s.info.OutstandingWork()) |
| 45 | +} |
| 46 | + |
| 47 | +// A memory checkpoint taken in place schedules the harvest as tracked work, |
| 48 | +// which outlives the request and ends when the snapshot's seal settles. |
| 49 | +func TestHarvestCheckpointPrefetchAsync_SchedulesAndTracksWork(t *testing.T) { |
| 50 | + t.Parallel() |
| 51 | + |
| 52 | + ff := harvestFlagClient(t) |
| 53 | + synctest.Test(t, func(t *testing.T) { |
| 54 | + s := &Server{info: &service.ServiceInfo{}, featureFlags: ff} |
| 55 | + seal := utils.NewSetOnce[build.Diff]() |
| 56 | + res := &snapshotResult{rootfsDiff: build.NewDeferredDiff("rootfs", 4096, seal)} |
| 57 | + ctx, cancel := context.WithCancel(t.Context()) |
| 58 | + defer cancel() |
| 59 | + |
| 60 | + s.harvestCheckpointPrefetchAsync(ctx, testHarvestSandbox(), res, &orchestrator.SandboxCheckpointRequest{BuildId: "build-1"}) |
| 61 | + require.Equal(t, int64(1), s.info.OutstandingWork()) |
| 62 | + cancel() |
| 63 | + synctest.Wait() |
| 64 | + require.Equal(t, int64(1), s.info.OutstandingWork(), "request cancellation must not release harvest work") |
| 65 | + require.NoError(t, seal.SetError(build.ErrDeferredSealFailed)) |
| 66 | + synctest.Wait() |
| 67 | + require.Zero(t, s.info.OutstandingWork()) |
| 68 | + }) |
| 69 | +} |
| 70 | + |
| 71 | +// An in-place checkpoint through the CoW window hands the harvest a memfile |
| 72 | +// that is still being swept: the harvest must not resume until that seal |
| 73 | +// settles, and a failed seal skips it without touching the rootfs. |
| 74 | +func TestHarvestResumePrefetchAsync_WaitsForDeferredMemorySeal(t *testing.T) { |
| 75 | + t.Parallel() |
| 76 | + |
| 77 | + for _, tc := range []struct { |
| 78 | + name string |
| 79 | + memorySealErr error |
| 80 | + }{ |
| 81 | + {name: "seal fails", memorySealErr: errors.New("window cancelled")}, |
| 82 | + {name: "seal settles"}, |
| 83 | + } { |
| 84 | + t.Run(tc.name, func(t *testing.T) { |
| 85 | + t.Parallel() |
| 86 | + |
| 87 | + ff := harvestFlagClient(t) |
| 88 | + synctest.Test(t, func(t *testing.T) { |
| 89 | + s := &Server{info: &service.ServiceInfo{}, featureFlags: ff} |
| 90 | + memorySealed := make(chan error, 1) |
| 91 | + rootfsSeal := utils.NewSetOnce[build.Diff]() |
| 92 | + var rootfsWaited atomic.Bool |
| 93 | + res := &snapshotResult{ |
| 94 | + rootfsDiff: build.NewDeferredDiff("rootfs", 4096, rootfsSeal), |
| 95 | + memoryExportDeferred: true, |
| 96 | + waitMemorySealed: func(ctx context.Context) error { |
| 97 | + select { |
| 98 | + case err := <-memorySealed: |
| 99 | + return err |
| 100 | + case <-ctx.Done(): |
| 101 | + return ctx.Err() |
| 102 | + } |
| 103 | + }, |
| 104 | + } |
| 105 | + // The rootfs promise is only consulted once memory has sealed; a |
| 106 | + // failed rootfs seal then ends the harvest without a resume. |
| 107 | + go func() { |
| 108 | + <-time.After(10 * time.Millisecond) |
| 109 | + rootfsWaited.Store(true) |
| 110 | + _ = rootfsSeal.SetError(build.ErrDeferredSealFailed) |
| 111 | + }() |
| 112 | + |
| 113 | + s.harvestResumePrefetchAsync(t.Context(), testHarvestSandbox(), res, "build-1", nil, harvestSourceCheckpoint) |
| 114 | + require.Equal(t, int64(1), s.info.OutstandingWork()) |
| 115 | + <-time.After(500 * time.Millisecond) |
| 116 | + synctest.Wait() |
| 117 | + require.Equal(t, int64(1), s.info.OutstandingWork(), "the harvest must hold until the memory seal settles") |
| 118 | + |
| 119 | + memorySealed <- tc.memorySealErr |
| 120 | + synctest.Wait() |
| 121 | + require.Zero(t, s.info.OutstandingWork()) |
| 122 | + require.True(t, rootfsWaited.Load()) |
| 123 | + }) |
| 124 | + }) |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +// waitSnapshotSealed orders the waits memfile first, names which seal failed, |
| 129 | +// and is a no-op when nothing was deferred. |
| 130 | +func TestWaitSnapshotSealed(t *testing.T) { |
| 131 | + t.Parallel() |
| 132 | + |
| 133 | + require.NoError(t, waitSnapshotSealed(t.Context(), &snapshotResult{rootfsDiff: &build.NoDiff{}})) |
| 134 | + |
| 135 | + memErr := errors.New("sweep aborted") |
| 136 | + err := waitSnapshotSealed(t.Context(), &snapshotResult{ |
| 137 | + rootfsDiff: &build.NoDiff{}, |
| 138 | + memoryExportDeferred: true, |
| 139 | + waitMemorySealed: func(context.Context) error { return memErr }, |
| 140 | + }) |
| 141 | + require.ErrorIs(t, err, memErr) |
| 142 | + require.ErrorContains(t, err, "memory seal") |
| 143 | + |
| 144 | + rootfsSeal := utils.NewSetOnce[build.Diff]() |
| 145 | + require.NoError(t, rootfsSeal.SetError(build.ErrDeferredSealFailed)) |
| 146 | + memoryWaited := false |
| 147 | + err = waitSnapshotSealed(t.Context(), &snapshotResult{ |
| 148 | + rootfsDiff: build.NewDeferredDiff("rootfs", 4096, rootfsSeal), |
| 149 | + memoryExportDeferred: true, |
| 150 | + waitMemorySealed: func(context.Context) error { |
| 151 | + memoryWaited = true |
| 152 | + |
| 153 | + return nil |
| 154 | + }, |
| 155 | + }) |
| 156 | + require.ErrorIs(t, err, build.ErrDeferredSealFailed) |
| 157 | + require.ErrorContains(t, err, "rootfs seal") |
| 158 | + require.True(t, memoryWaited, "memory seal is awaited before the rootfs seal") |
| 159 | + |
| 160 | + // A deferred flag with no waiter (older snapshot shape) is treated as sealed. |
| 161 | + require.NoError(t, waitSnapshotSealed(t.Context(), &snapshotResult{rootfsDiff: &build.NoDiff{}, memoryExportDeferred: true})) |
| 162 | +} |
0 commit comments