github.com/grafana/pyroscope@v1.18.0/pkg/compactionworker/worker_test.go (about)

     1  package compactionworker
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"path/filepath"
     7  	"strings"
     8  	"sync"
     9  	"sync/atomic"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/go-kit/log"
    14  	"github.com/prometheus/client_golang/prometheus"
    15  	"github.com/stretchr/testify/assert"
    16  	"github.com/stretchr/testify/mock"
    17  	"github.com/stretchr/testify/require"
    18  	thanosstore "github.com/thanos-io/objstore"
    19  	"google.golang.org/grpc"
    20  
    21  	metastorev1 "github.com/grafana/pyroscope/api/gen/proto/go/metastore/v1"
    22  	"github.com/grafana/pyroscope/pkg/block"
    23  	"github.com/grafana/pyroscope/pkg/objstore"
    24  	"github.com/grafana/pyroscope/pkg/test"
    25  	"github.com/grafana/pyroscope/pkg/test/mocks/mockmetastorev1"
    26  	"github.com/grafana/pyroscope/pkg/test/mocks/mockobjstore"
    27  )
    28  
    29  type MetastoreClientMock struct {
    30  	*mockmetastorev1.MockCompactionServiceClient
    31  	*mockmetastorev1.MockIndexServiceClient
    32  }
    33  
    34  func createTestWorker(t *testing.T, client MetastoreClient, compactFn compactFunc, bucket objstore.Bucket) *Worker {
    35  	config := Config{
    36  		JobConcurrency:     2,
    37  		JobPollInterval:    100 * time.Millisecond,
    38  		RequestTimeout:     time.Second,
    39  		CleanupMaxDuration: time.Second,
    40  		TempDir:            t.TempDir(),
    41  	}
    42  
    43  	worker, err := New(
    44  		log.NewNopLogger(),
    45  		config,
    46  		client,
    47  		bucket,
    48  		prometheus.NewRegistry(),
    49  		nil, // ruler
    50  		nil, // exporter
    51  	)
    52  
    53  	require.NoError(t, err)
    54  	worker.compactFn = compactFn
    55  	return worker
    56  }
    57  
    58  func runWorker(w *Worker) {
    59  	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    60  	defer cancel()
    61  
    62  	var wg sync.WaitGroup
    63  	wg.Add(1)
    64  	go func() {
    65  		defer wg.Done()
    66  		svc := w.Service()
    67  		_ = svc.StartAsync(ctx)
    68  		_ = svc.AwaitRunning(ctx)
    69  		time.Sleep(500 * time.Millisecond)
    70  		svc.StopAsync()
    71  		_ = svc.AwaitTerminated(ctx)
    72  	}()
    73  
    74  	wg.Wait()
    75  }
    76  
    77  func TestWorker_SuccessfulCompaction(t *testing.T) {
    78  	bucket := mockobjstore.NewMockBucket(t)
    79  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
    80  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
    81  	client := &MetastoreClientMock{
    82  		MockCompactionServiceClient: compactionClient,
    83  		MockIndexServiceClient:      indexClient,
    84  	}
    85  
    86  	block1ID := test.ULID("2024-01-01T10:00:00Z")
    87  	block2ID := test.ULID("2024-01-01T11:00:00Z")
    88  	compactedBlockID := test.ULID("2024-01-01T12:00:00Z")
    89  
    90  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
    91  		require.Len(t, blocks, 2)
    92  		assert.Equal(t, block1ID, blocks[0].Id)
    93  		assert.Equal(t, block2ID, blocks[1].Id)
    94  		return []*metastorev1.BlockMeta{{Id: compactedBlockID, Tenant: 1, Shard: 1, CompactionLevel: 2}}, nil
    95  	}
    96  
    97  	w := createTestWorker(t, client, compactFn, bucket)
    98  
    99  	job := &metastorev1.CompactionJob{
   100  		Name:            "test-job",
   101  		Tenant:          "test-tenant",
   102  		Shard:           1,
   103  		CompactionLevel: 1,
   104  		SourceBlocks:    []string{block1ID, block2ID},
   105  	}
   106  	assignment := &metastorev1.CompactionJobAssignment{
   107  		Name:  "test-job",
   108  		Token: 12345,
   109  	}
   110  
   111  	metadata := []*metastorev1.BlockMeta{
   112  		{Id: block1ID, Tenant: 1, Shard: 1},
   113  		{Id: block2ID, Tenant: 1, Shard: 1},
   114  	}
   115  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   116  		return req.JobCapacity > 0
   117  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   118  		CompactionJobs: []*metastorev1.CompactionJob{job},
   119  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   120  	}, nil).Once()
   121  
   122  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.GetBlockMetadataResponse{
   123  		Blocks: metadata,
   124  	}, nil).Once()
   125  
   126  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   127  		return len(req.StatusUpdates) > 0 && req.StatusUpdates[0].Status == metastorev1.CompactionJobStatus_COMPACTION_STATUS_SUCCESS
   128  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Once()
   129  
   130  	// Additional polls should return empty responses.
   131  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   132  
   133  	runWorker(w)
   134  }
   135  
   136  func TestWorker_CompactionFailure(t *testing.T) {
   137  	bucket := mockobjstore.NewMockBucket(t)
   138  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   139  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   140  	client := &MetastoreClientMock{
   141  		MockCompactionServiceClient: compactionClient,
   142  		MockIndexServiceClient:      indexClient,
   143  	}
   144  
   145  	block1ID := test.ULID("2024-01-01T10:00:00Z")
   146  
   147  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   148  		return nil, errors.New("compaction failed")
   149  	}
   150  
   151  	w := createTestWorker(t, client, compactFn, bucket)
   152  
   153  	job := &metastorev1.CompactionJob{
   154  		Name:            "test-job",
   155  		Tenant:          "test-tenant",
   156  		Shard:           1,
   157  		CompactionLevel: 1,
   158  		SourceBlocks:    []string{block1ID},
   159  	}
   160  	assignment := &metastorev1.CompactionJobAssignment{
   161  		Name:  "test-job",
   162  		Token: 12345,
   163  	}
   164  
   165  	metadata := []*metastorev1.BlockMeta{
   166  		{Id: block1ID, Tenant: 1, Shard: 1},
   167  	}
   168  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   169  		return req.JobCapacity > 0
   170  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   171  		CompactionJobs: []*metastorev1.CompactionJob{job},
   172  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   173  	}, nil).Once()
   174  
   175  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.GetBlockMetadataResponse{
   176  		Blocks: metadata,
   177  	}, nil).Once()
   178  
   179  	bucket.EXPECT().IsObjNotFoundErr(mock.Anything).Return(false).Maybe()
   180  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   181  
   182  	runWorker(w)
   183  }
   184  
   185  func TestWorker_JobCancellation(t *testing.T) {
   186  	bucket := mockobjstore.NewMockBucket(t)
   187  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   188  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   189  	client := &MetastoreClientMock{
   190  		MockCompactionServiceClient: compactionClient,
   191  		MockIndexServiceClient:      indexClient,
   192  	}
   193  
   194  	block1ID := test.ULID("2024-01-01T10:00:00Z")
   195  
   196  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   197  		return nil, context.Canceled
   198  	}
   199  
   200  	w := createTestWorker(t, client, compactFn, bucket)
   201  
   202  	job := &metastorev1.CompactionJob{
   203  		Name:            "test-job",
   204  		Tenant:          "test-tenant",
   205  		Shard:           1,
   206  		CompactionLevel: 1,
   207  		SourceBlocks:    []string{block1ID},
   208  	}
   209  	assignment := &metastorev1.CompactionJobAssignment{
   210  		Name:  "test-job",
   211  		Token: 12345,
   212  	}
   213  
   214  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   215  		return req.JobCapacity > 0
   216  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   217  		CompactionJobs: []*metastorev1.CompactionJob{job},
   218  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   219  	}, nil).Once()
   220  
   221  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.GetBlockMetadataResponse{
   222  		Blocks: []*metastorev1.BlockMeta{{Id: block1ID, Tenant: 1, Shard: 1}},
   223  	}, nil).Maybe()
   224  
   225  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   226  
   227  	runWorker(w)
   228  }
   229  
   230  func TestWorker_TombstoneHandling(t *testing.T) {
   231  	bucket := mockobjstore.NewMockBucket(t)
   232  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   233  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   234  	client := &MetastoreClientMock{
   235  		MockCompactionServiceClient: compactionClient,
   236  		MockIndexServiceClient:      indexClient,
   237  	}
   238  
   239  	sourceBlockID := test.ULID("2024-01-01T11:00:00Z")
   240  	compactedBlockID := test.ULID("2024-01-01T12:00:00Z")
   241  	oldBlock1ID := test.ULID("2024-01-01T08:00:00Z")
   242  	oldBlock2ID := test.ULID("2024-01-01T09:00:00Z")
   243  
   244  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   245  		return []*metastorev1.BlockMeta{{Id: compactedBlockID, Tenant: 1, Shard: 1, CompactionLevel: 2}}, nil
   246  	}
   247  
   248  	w := createTestWorker(t, client, compactFn, bucket)
   249  
   250  	tombstones := []*metastorev1.Tombstones{{
   251  		Blocks: &metastorev1.BlockTombstones{
   252  			Name:            "test-tombstone",
   253  			Tenant:          "test-tenant",
   254  			Shard:           1,
   255  			CompactionLevel: 1,
   256  			Blocks:          []string{oldBlock1ID, oldBlock2ID},
   257  		},
   258  	}}
   259  
   260  	job := &metastorev1.CompactionJob{
   261  		Name:            "test-job",
   262  		Tenant:          "test-tenant",
   263  		Shard:           1,
   264  		CompactionLevel: 1,
   265  		SourceBlocks:    []string{sourceBlockID},
   266  		Tombstones:      tombstones,
   267  	}
   268  	assignment := &metastorev1.CompactionJobAssignment{
   269  		Name:  "test-job",
   270  		Token: 12345,
   271  	}
   272  
   273  	metadata := []*metastorev1.BlockMeta{
   274  		{Id: sourceBlockID, Tenant: 1, Shard: 1},
   275  	}
   276  
   277  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   278  		return req.JobCapacity > 0
   279  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   280  		CompactionJobs: []*metastorev1.CompactionJob{job},
   281  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   282  	}, nil).Once()
   283  
   284  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.GetBlockMetadataResponse{
   285  		Blocks: metadata,
   286  	}, nil).Once()
   287  
   288  	bucket.EXPECT().Delete(mock.Anything, mock.MatchedBy(func(path string) bool {
   289  		return (strings.Contains(path, oldBlock1ID) || strings.Contains(path, oldBlock2ID)) &&
   290  			strings.Contains(path, "test-tenant")
   291  	})).Return(nil).Times(2)
   292  
   293  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   294  		return len(req.StatusUpdates) > 0 && req.StatusUpdates[0].Status == metastorev1.CompactionJobStatus_COMPACTION_STATUS_SUCCESS
   295  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Once()
   296  
   297  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   298  
   299  	runWorker(w)
   300  }
   301  
   302  func TestWorker_MetadataNotFound(t *testing.T) {
   303  	bucket := mockobjstore.NewMockBucket(t)
   304  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   305  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   306  	client := &MetastoreClientMock{
   307  		MockCompactionServiceClient: compactionClient,
   308  		MockIndexServiceClient:      indexClient,
   309  	}
   310  
   311  	missingBlockID := test.ULID("2024-01-01T10:00:00Z")
   312  
   313  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   314  		t.Error("compactFn should not be called when metadata is not found")
   315  		return nil, errors.New("should not be called")
   316  	}
   317  
   318  	w := createTestWorker(t, client, compactFn, bucket)
   319  
   320  	job := &metastorev1.CompactionJob{
   321  		Name:            "test-job",
   322  		Tenant:          "test-tenant",
   323  		Shard:           1,
   324  		CompactionLevel: 1,
   325  		SourceBlocks:    []string{missingBlockID},
   326  	}
   327  	assignment := &metastorev1.CompactionJobAssignment{
   328  		Name:  "test-job",
   329  		Token: 12345,
   330  	}
   331  
   332  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   333  		return req.JobCapacity > 0
   334  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   335  		CompactionJobs: []*metastorev1.CompactionJob{job},
   336  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   337  	}, nil).Once()
   338  
   339  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return((*metastorev1.GetBlockMetadataResponse)(nil), errors.New("metadata not found")).Once()
   340  
   341  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   342  
   343  	runWorker(w)
   344  }
   345  
   346  func TestWorker_ShardTombstoneHandling(t *testing.T) {
   347  	bucket := mockobjstore.NewMockBucket(t)
   348  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   349  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   350  	client := &MetastoreClientMock{
   351  		MockCompactionServiceClient: compactionClient,
   352  		MockIndexServiceClient:      indexClient,
   353  	}
   354  
   355  	sourceBlockID := test.ULID("2024-01-01T11:00:00Z")
   356  	compactedBlockID := test.ULID("2024-01-01T12:00:00Z")
   357  	oldBlock1ID := test.ULID("2024-01-01T08:00:00Z")
   358  	oldBlock2ID := test.ULID("2024-01-01T09:00:00Z")
   359  	newBlock1ID := test.ULID("2024-01-01T10:30:00Z")
   360  	newBlock2ID := test.ULID("2024-01-01T11:30:00Z")
   361  
   362  	compactFn := func(ctx context.Context, blocks []*metastorev1.BlockMeta, storage objstore.Bucket, options ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   363  		return []*metastorev1.BlockMeta{
   364  			{Id: compactedBlockID, Tenant: 1, Shard: 1, CompactionLevel: 2},
   365  		}, nil
   366  	}
   367  
   368  	w := createTestWorker(t, client, compactFn, bucket)
   369  
   370  	tombstoneTime := test.Time("2024-01-01T09:00:00Z")
   371  	duration := time.Hour
   372  	shardTombstone := &metastorev1.Tombstones{
   373  		Shard: &metastorev1.ShardTombstone{
   374  			Name:      "test-shard-tombstone",
   375  			Tenant:    "test-tenant",
   376  			Shard:     1,
   377  			Timestamp: tombstoneTime.UnixNano(),
   378  			Duration:  int64(duration),
   379  		},
   380  	}
   381  
   382  	job := &metastorev1.CompactionJob{
   383  		Name:            "test-job",
   384  		Tenant:          "test-tenant",
   385  		Shard:           1,
   386  		CompactionLevel: 1,
   387  		SourceBlocks:    []string{sourceBlockID},
   388  		Tombstones:      []*metastorev1.Tombstones{shardTombstone},
   389  	}
   390  	assignment := &metastorev1.CompactionJobAssignment{
   391  		Name:  "test-job",
   392  		Token: 12345,
   393  	}
   394  
   395  	metadata := []*metastorev1.BlockMeta{
   396  		{Id: sourceBlockID, Tenant: 1, Shard: 1},
   397  	}
   398  
   399  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   400  		return req.JobCapacity > 0
   401  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{
   402  		CompactionJobs: []*metastorev1.CompactionJob{job},
   403  		Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   404  	}, nil).Once()
   405  
   406  	indexClient.EXPECT().GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.GetBlockMetadataResponse{
   407  		Blocks: metadata,
   408  	}, nil).Once()
   409  
   410  	expectedDir := block.BuildObjectDir("test-tenant", 1)
   411  	bucket.EXPECT().Iter(mock.Anything, expectedDir, mock.Anything, mock.Anything).Run(
   412  		func(ctx context.Context, dir string, fn func(string) error, options ...thanosstore.IterOption) {
   413  			blockPaths := []string{
   414  				block.BuildObjectPath("test-tenant", 1, 1, oldBlock1ID),   // Should be deleted
   415  				block.BuildObjectPath("test-tenant", 1, 1, oldBlock2ID),   // Should be deleted
   416  				block.BuildObjectPath("test-tenant", 1, 1, newBlock1ID),   // SkipAll
   417  				block.BuildObjectPath("test-tenant", 1, 1, newBlock2ID),   //
   418  				block.BuildObjectPath("test-tenant", 1, 1, sourceBlockID), //
   419  			}
   420  			for _, path := range blockPaths {
   421  				if err := fn(path); err != nil {
   422  					return // Return(filepath.SkipAll).Once()
   423  				}
   424  			}
   425  		}).Return(filepath.SkipAll).Once()
   426  
   427  	bucket.EXPECT().Delete(mock.Anything, block.BuildObjectPath("test-tenant", 1, 1, oldBlock1ID)).Return(nil).Once()
   428  	bucket.EXPECT().Delete(mock.Anything, block.BuildObjectPath("test-tenant", 1, 1, oldBlock2ID)).Return(nil).Once()
   429  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.MatchedBy(func(req *metastorev1.PollCompactionJobsRequest) bool {
   430  		return len(req.StatusUpdates) > 0 && req.StatusUpdates[0].Status == metastorev1.CompactionJobStatus_COMPACTION_STATUS_SUCCESS
   431  	}), mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Once()
   432  
   433  	compactionClient.EXPECT().PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   434  
   435  	runWorker(w)
   436  }
   437  
   438  var skipCompactionFn = func(context.Context, []*metastorev1.BlockMeta, objstore.Bucket, ...block.CompactionOption) ([]*metastorev1.BlockMeta, error) {
   439  	return nil, nil
   440  }
   441  
   442  func TestWorker_CleanupMaxDurationAtShutdown(t *testing.T) {
   443  	bucket := mockobjstore.NewMockBucket(t)
   444  	compactionClient := mockmetastorev1.NewMockCompactionServiceClient(t)
   445  	indexClient := mockmetastorev1.NewMockIndexServiceClient(t)
   446  	client := &MetastoreClientMock{
   447  		MockCompactionServiceClient: compactionClient,
   448  		MockIndexServiceClient:      indexClient,
   449  	}
   450  
   451  	config := Config{
   452  		JobConcurrency:     1,
   453  		JobPollInterval:    100 * time.Millisecond,
   454  		RequestTimeout:     time.Second,
   455  		CleanupMaxDuration: 15 * time.Second,
   456  		TempDir:            t.TempDir(),
   457  	}
   458  
   459  	worker, err := New(
   460  		log.NewNopLogger(),
   461  		config,
   462  		client,
   463  		bucket,
   464  		nil, // registry
   465  		nil, // ruler
   466  		nil, // exporter
   467  	)
   468  	require.NoError(t, err)
   469  	worker.compactFn = skipCompactionFn
   470  
   471  	job := &metastorev1.CompactionJob{Name: "test-job"}
   472  	assignment := &metastorev1.CompactionJobAssignment{Name: job.Name, Token: 12345}
   473  	job.Tombstones = []*metastorev1.Tombstones{{
   474  		Blocks: &metastorev1.BlockTombstones{
   475  			Name:            "test-tombstone",
   476  			Tenant:          "test-tenant",
   477  			Shard:           1,
   478  			CompactionLevel: 1,
   479  			Blocks:          []string{"a", "b"},
   480  		},
   481  	}}
   482  
   483  	var once sync.Once
   484  	done := make(chan struct{})
   485  	triggerShutdown := func(context.Context, *metastorev1.PollCompactionJobsRequest, ...grpc.CallOption) {
   486  		once.Do(func() { close(done) })
   487  	}
   488  
   489  	compactionClient.EXPECT().
   490  		PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).
   491  		Run(triggerShutdown).
   492  		Return(&metastorev1.PollCompactionJobsResponse{
   493  			CompactionJobs: []*metastorev1.CompactionJob{job},
   494  			Assignments:    []*metastorev1.CompactionJobAssignment{assignment},
   495  		}, nil).Once()
   496  
   497  	indexClient.EXPECT().
   498  		GetBlockMetadata(mock.Anything, mock.Anything, mock.Anything).
   499  		Return(&metastorev1.GetBlockMetadataResponse{}, nil).
   500  		Once()
   501  
   502  	var blocksDeleted atomic.Int32
   503  	bucket.EXPECT().
   504  		Delete(mock.Anything, mock.Anything).
   505  		Run(func(context.Context, string) {
   506  			blocksDeleted.Add(1)
   507  			time.Sleep(100 * time.Millisecond)
   508  		}).Return(nil).Times(2)
   509  
   510  	compactionClient.EXPECT().
   511  		PollCompactionJobs(mock.Anything, mock.Anything, mock.Anything).
   512  		Return(&metastorev1.PollCompactionJobsResponse{}, nil).Maybe()
   513  
   514  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   515  	defer cancel()
   516  
   517  	svc := worker.Service()
   518  	assert.NoError(t, svc.StartAsync(ctx))
   519  	assert.NoError(t, svc.AwaitRunning(ctx))
   520  
   521  	// Wait for the job to be polled and shutdown immediately.
   522  	<-done
   523  	svc.StopAsync()
   524  	assert.NoError(t, svc.AwaitTerminated(ctx))
   525  
   526  	require.Equal(t, 2, int(blocksDeleted.Load()))
   527  }