@@ -219,6 +219,58 @@ func TestCleaner_PreservesFutureScoredExpirationEntry(t *testing.T) {
219219 require .NoError (t , err , "future-scored entry must not be pruned" )
220220}
221221
222+ // TestCleaner_EvictsStaleExpiredSandbox covers the new evictExpired path:
223+ // a sandbox whose EndTime is older than StaleCutoff must be Remove()'d by
224+ // the cleaner so its JSON key, per-team index entry, and globalExpirationSet
225+ // member all disappear.
226+ func TestCleaner_EvictsStaleExpiredSandbox (t * testing.T ) {
227+ t .Parallel ()
228+
229+ storage , client := setupTestStorage (t )
230+ ctx := t .Context ()
231+
232+ sbx := createTestSandbox ("stale-expired-" + uuid .NewString ())
233+ sbx .EndTime = time .Now ().Add (- sandbox .StaleCutoff - time .Minute )
234+ require .NoError (t , storage .Add (ctx , sbx ))
235+
236+ cleaner := NewCleaner (storage )
237+ require .NoError (t , cleaner .RunOnce (ctx ))
238+
239+ _ , err := storage .Get (ctx , sbx .TeamID , sbx .SandboxID )
240+ require .ErrorIs (t , err , sandbox .ErrNotFound , "stale expired sandbox JSON should be removed" )
241+
242+ _ , err = client .ZScore (ctx , globalExpirationSet ,
243+ expirationMember (sbx .TeamID .String (), sbx .SandboxID )).Result ()
244+ require .ErrorIs (t , err , redis .Nil , "stale expired sandbox should be removed from globalExpirationSet" )
245+
246+ isMember , err := client .SIsMember (ctx ,
247+ GetSandboxStorageTeamIndexKey (sbx .TeamID .String ()), sbx .SandboxID ).Result ()
248+ require .NoError (t , err )
249+ require .False (t , isMember , "stale expired sandbox should be removed from per-team index" )
250+ }
251+
252+ // TestCleaner_PreservesRecentlyExpiredSandbox guards the StaleCutoff window
253+ // inside evictExpired: a sandbox that has just expired (EndTime in the past
254+ // but newer than StaleCutoff) is still the evictor's responsibility — the
255+ // cleaner must leave it alone so we don't race the evictor.
256+ func TestCleaner_PreservesRecentlyExpiredSandbox (t * testing.T ) {
257+ t .Parallel ()
258+
259+ storage , _ := setupTestStorage (t )
260+ ctx := t .Context ()
261+
262+ sbx := createTestSandbox ("fresh-expired-" + uuid .NewString ())
263+ sbx .EndTime = time .Now ().Add (- time .Second )
264+ require .NoError (t , storage .Add (ctx , sbx ))
265+
266+ cleaner := NewCleaner (storage )
267+ require .NoError (t , cleaner .RunOnce (ctx ))
268+
269+ got , err := storage .Get (ctx , sbx .TeamID , sbx .SandboxID )
270+ require .NoError (t , err , "recently expired sandbox must survive — eviction is the evictor's job" )
271+ require .Equal (t , sbx .SandboxID , got .SandboxID )
272+ }
273+
222274// Compile-time guard so future refactors of sandbox.StaleCutoff get noticed
223275// here: the cleaner's correctness depends on it being > 0.
224276var _ = func () bool {
0 commit comments