@@ -3223,6 +3223,110 @@ Y_UNIT_TEST_SUITE(TFileSystemTest)
32233223 AtomicGet (counters->GetCounter (" InProgress" )->GetAtomic ()));
32243224 }
32253225
3226+ Y_UNIT_TEST (ShouldDrainHandleOpsQueueBackToBack)
3227+ {
3228+ // AsyncHandleOperationPeriod defaults to 0, so a non-empty queue is
3229+ // drained back-to-back: every entry is rescheduled with zero delay.
3230+ // With a frozen clock, running only the tasks due "now" fires just the
3231+ // zero-delay tasks, so the whole queue must drain without advancing
3232+ // time. A non-zero period would leave every processing task in the
3233+ // future and this drain would never complete.
3234+ constexpr ui32 requestCount = 3 ;
3235+
3236+ NProto::TFileStoreFeatures features;
3237+ features.SetAsyncDestroyHandleEnabled (true );
3238+
3239+ auto timer = std::make_shared<TTestTimer>();
3240+ auto scheduler = std::make_shared<TTestScheduler>(timer->Now ());
3241+ TBootstrap bootstrap (timer, scheduler, features);
3242+
3243+ std::atomic_uint handlerCalled = 0 ;
3244+ bootstrap.Service ->SetHandlerDestroyHandle (
3245+ [&](auto , auto )
3246+ {
3247+ ++handlerCalled;
3248+ return MakeFuture (NProto::TDestroyHandleResponse{});
3249+ });
3250+
3251+ auto inProgress =
3252+ bootstrap.Counters ->FindSubgroup (" component" , " fs_ut" )
3253+ ->FindSubgroup (" request" , " DestroyHandle" )
3254+ ->GetCounter (" InProgress" );
3255+
3256+ bootstrap.Start ();
3257+ Y_DEFER {
3258+ bootstrap.Stop ();
3259+ };
3260+
3261+ for (ui32 i = 0 ; i < requestCount; ++i) {
3262+ auto future = bootstrap.Fuse ->SendRequest <TReleaseRequest>(
3263+ 10 + i,
3264+ 2 + i,
3265+ O_RDONLY );
3266+ UNIT_ASSERT_NO_EXCEPTION (future.GetValue (WaitTimeout));
3267+ }
3268+
3269+ UNIT_ASSERT (WaitForCondition (
3270+ WaitTimeout,
3271+ [&]
3272+ {
3273+ scheduler->RunAllScheduledTasksUntilNow ();
3274+ return handlerCalled.load () == requestCount
3275+ && AtomicGet (inProgress->GetAtomic ()) == 0 ;
3276+ }));
3277+ }
3278+
3279+ Y_UNIT_TEST (ShouldBackOffWhenHandleOpsQueueEmpty)
3280+ {
3281+ // On an empty queue processing is rescheduled with a non zero default
3282+ // to avoid a busy loop.
3283+ constexpr auto EmptyQueueBackoff = TDuration::MilliSeconds (50 );
3284+
3285+ NProto::TFileStoreFeatures features;
3286+ features.SetAsyncDestroyHandleEnabled (true );
3287+
3288+ auto timer = std::make_shared<TTestTimer>();
3289+ auto scheduler = std::make_shared<TTestScheduler>(timer->Now ());
3290+ TBootstrap bootstrap (timer, scheduler, features);
3291+
3292+ std::atomic_uint handlerCalled = 0 ;
3293+ bootstrap.Service ->SetHandlerDestroyHandle (
3294+ [&](auto , auto )
3295+ {
3296+ ++handlerCalled;
3297+ return MakeFuture (NProto::TDestroyHandleResponse{});
3298+ });
3299+
3300+ bootstrap.Start ();
3301+ Y_DEFER {
3302+ bootstrap.Stop ();
3303+ };
3304+
3305+ scheduler->RunAllScheduledTasksUntilNow ();
3306+
3307+ auto future = bootstrap.Fuse ->SendRequest <TReleaseRequest>(
3308+ 10 ,
3309+ 2 ,
3310+ O_RDONLY );
3311+ UNIT_ASSERT_NO_EXCEPTION (future.GetValue (WaitTimeout));
3312+
3313+ // The only scheduled poll is EmptyQueueBackoff in the future, so at the
3314+ // current (frozen) time it does not fire and the entry stays pending.
3315+ scheduler->RunAllScheduledTasksUntilNow ();
3316+ UNIT_ASSERT_VALUES_EQUAL (0U , handlerCalled.load ());
3317+
3318+ // Once the backoff elapses the poll fires and picks up the entry.
3319+ timer->AdvanceTime (EmptyQueueBackoff);
3320+ scheduler->AdvanceTime (EmptyQueueBackoff);
3321+ UNIT_ASSERT (WaitForCondition (
3322+ WaitTimeout,
3323+ [&]
3324+ {
3325+ scheduler->RunAllScheduledTasksUntilNow ();
3326+ return handlerCalled.load () == 1 ;
3327+ }));
3328+ }
3329+
32263330 Y_UNIT_TEST (ShouldProcessReadOnlyDestroyHandleRequestsAsynchronously)
32273331 {
32283332 NProto::TFileStoreFeatures features;
0 commit comments