github.com/opensearch-project/opensearch-go/v2@v2.3.0/opensearchutil/bulk_indexer_internal_test.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  //
     3  // The OpenSearch Contributors require contributions made to
     4  // this file be licensed under the Apache-2.0 license or a
     5  // compatible open source license.
     6  //
     7  // Modifications Copyright OpenSearch Contributors. See
     8  // GitHub history for details.
     9  
    10  // Licensed to Elasticsearch B.V. under one or more contributor
    11  // license agreements. See the NOTICE file distributed with
    12  // this work for additional information regarding copyright
    13  // ownership. Elasticsearch B.V. licenses this file to you under
    14  // the Apache License, Version 2.0 (the "License"); you may
    15  // not use this file except in compliance with the License.
    16  // You may obtain a copy of the License at
    17  //
    18  //    http://www.apache.org/licenses/LICENSE-2.0
    19  //
    20  // Unless required by applicable law or agreed to in writing,
    21  // software distributed under the License is distributed on an
    22  // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    23  // KIND, either express or implied.  See the License for the
    24  // specific language governing permissions and limitations
    25  // under the License.
    26  
    27  //go:build !integration
    28  // +build !integration
    29  
    30  package opensearchutil
    31  
    32  import (
    33  	"bytes"
    34  	"context"
    35  	"encoding/json"
    36  	"fmt"
    37  	"io"
    38  	"io/ioutil"
    39  	"log"
    40  	"net/http"
    41  	"os"
    42  	"reflect"
    43  	"strconv"
    44  	"strings"
    45  	"sync"
    46  	"sync/atomic"
    47  	"testing"
    48  	"time"
    49  
    50  	"github.com/opensearch-project/opensearch-go/v2"
    51  	"github.com/opensearch-project/opensearch-go/v2/opensearchtransport"
    52  )
    53  
    54  var infoBody = `{
    55    "version" : {
    56  	"number" : "1.0.0",
    57  	"distribution" : "opensearch"
    58    }
    59  }`
    60  
    61  var defaultRoundTripFunc = func(*http.Request) (*http.Response, error) {
    62  	return &http.Response{Body: ioutil.NopCloser(strings.NewReader(`{}`))}, nil
    63  }
    64  
    65  type mockTransport struct {
    66  	RoundTripFunc func(*http.Request) (*http.Response, error)
    67  }
    68  
    69  func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    70  	if t.RoundTripFunc == nil {
    71  		return defaultRoundTripFunc(req)
    72  	}
    73  	return t.RoundTripFunc(req)
    74  }
    75  
    76  func TestBulkIndexer(t *testing.T) {
    77  	t.Run("Basic", func(t *testing.T) {
    78  		var (
    79  			wg sync.WaitGroup
    80  
    81  			countReqs int
    82  			testfile  string
    83  			numItems  = 6
    84  		)
    85  
    86  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{
    87  			RoundTripFunc: func(request *http.Request) (*http.Response, error) {
    88  				if request.URL.Path == "/" {
    89  					return &http.Response{Header: http.Header{"Content-Type": []string{"application/json"}}, Body: ioutil.NopCloser(strings.NewReader(infoBody))}, nil
    90  				}
    91  
    92  				countReqs++
    93  				switch countReqs {
    94  				case 1:
    95  					testfile = "testdata/bulk_response_1a.json"
    96  				case 2:
    97  					testfile = "testdata/bulk_response_1b.json"
    98  				case 3:
    99  					testfile = "testdata/bulk_response_1c.json"
   100  				}
   101  				bodyContent, _ := ioutil.ReadFile(testfile)
   102  				return &http.Response{Body: ioutil.NopCloser(bytes.NewBuffer(bodyContent))}, nil
   103  			},
   104  		}})
   105  
   106  		cfg := BulkIndexerConfig{
   107  			NumWorkers:    1,
   108  			FlushBytes:    50,
   109  			FlushInterval: time.Hour, // Disable auto-flushing, because response doesn't match number of items
   110  			Client:        client}
   111  		if os.Getenv("DEBUG") != "" {
   112  			cfg.DebugLogger = log.New(os.Stdout, "", 0)
   113  		}
   114  
   115  		bi, _ := NewBulkIndexer(cfg)
   116  
   117  		for i := 1; i <= numItems; i++ {
   118  			wg.Add(1)
   119  			go func(i int) {
   120  				defer wg.Done()
   121  				err := bi.Add(context.Background(), BulkIndexerItem{
   122  					Action:     "foo",
   123  					DocumentID: strconv.Itoa(i),
   124  					Body:       strings.NewReader(fmt.Sprintf(`{"title":"foo-%d"}`, i)),
   125  				})
   126  				if err != nil {
   127  					t.Errorf("Unexpected error: %s", err)
   128  					return
   129  				}
   130  			}(i)
   131  		}
   132  		wg.Wait()
   133  
   134  		if err := bi.Close(context.Background()); err != nil {
   135  			t.Errorf("Unexpected error: %s", err)
   136  		}
   137  
   138  		stats := bi.Stats()
   139  
   140  		// added = numitems
   141  		if stats.NumAdded != uint64(numItems) {
   142  			t.Errorf("Unexpected NumAdded: want=%d, got=%d", numItems, stats.NumAdded)
   143  		}
   144  
   145  		// flushed = numitems - 1x conflict + 1x not_found
   146  		if stats.NumFlushed != uint64(numItems-2) {
   147  			t.Errorf("Unexpected NumFlushed: want=%d, got=%d", numItems-2, stats.NumFlushed)
   148  		}
   149  
   150  		// failed = 1x conflict + 1x not_found
   151  		if stats.NumFailed != 2 {
   152  			t.Errorf("Unexpected NumFailed: want=%d, got=%d", 2, stats.NumFailed)
   153  		}
   154  
   155  		// indexed = 1x
   156  		if stats.NumIndexed != 1 {
   157  			t.Errorf("Unexpected NumIndexed: want=%d, got=%d", 1, stats.NumIndexed)
   158  		}
   159  
   160  		// created = 1x
   161  		if stats.NumCreated != 1 {
   162  			t.Errorf("Unexpected NumCreated: want=%d, got=%d", 1, stats.NumCreated)
   163  		}
   164  
   165  		// deleted = 1x
   166  		if stats.NumDeleted != 1 {
   167  			t.Errorf("Unexpected NumDeleted: want=%d, got=%d", 1, stats.NumDeleted)
   168  		}
   169  
   170  		if stats.NumUpdated != 1 {
   171  			t.Errorf("Unexpected NumUpdated: want=%d, got=%d", 1, stats.NumUpdated)
   172  		}
   173  
   174  		// 3 items * 40 bytes, 2 workers, 1 request per worker
   175  		if stats.NumRequests != 3 {
   176  			t.Errorf("Unexpected NumRequests: want=%d, got=%d", 3, stats.NumRequests)
   177  		}
   178  	})
   179  
   180  	t.Run("Add() Timeout", func(t *testing.T) {
   181  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{}})
   182  		bi, _ := NewBulkIndexer(BulkIndexerConfig{NumWorkers: 1, Client: client})
   183  		ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
   184  		defer cancel()
   185  		time.Sleep(100 * time.Millisecond)
   186  
   187  		var errs []error
   188  		for i := 0; i < 10; i++ {
   189  			errs = append(errs, bi.Add(ctx, BulkIndexerItem{Action: "delete", DocumentID: "timeout"}))
   190  		}
   191  		if err := bi.Close(context.Background()); err != nil {
   192  			t.Errorf("Unexpected error: %s", err)
   193  		}
   194  
   195  		var gotError bool
   196  		for _, err := range errs {
   197  			if err != nil && err.Error() == "context deadline exceeded" {
   198  				gotError = true
   199  			}
   200  		}
   201  		if !gotError {
   202  			t.Errorf("Expected timeout error, but none in: %q", errs)
   203  		}
   204  	})
   205  
   206  	t.Run("Close() Cancel", func(t *testing.T) {
   207  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{}})
   208  		bi, _ := NewBulkIndexer(BulkIndexerConfig{
   209  			NumWorkers: 1,
   210  			FlushBytes: 1,
   211  			Client:     client,
   212  		})
   213  
   214  		for i := 0; i < 10; i++ {
   215  			bi.Add(context.Background(), BulkIndexerItem{Action: "foo"})
   216  		}
   217  
   218  		ctx, cancel := context.WithCancel(context.Background())
   219  		cancel()
   220  		if err := bi.Close(ctx); err == nil {
   221  			t.Errorf("Expected context cancelled error, but got: %v", err)
   222  		}
   223  	})
   224  
   225  	t.Run("Indexer Callback", func(t *testing.T) {
   226  		config := opensearch.Config{
   227  			Transport: &mockTransport{
   228  				RoundTripFunc: func(request *http.Request) (*http.Response, error) {
   229  					if request.URL.Path == "/" {
   230  						return &http.Response{Body: ioutil.NopCloser(strings.NewReader(infoBody))}, nil
   231  					}
   232  
   233  					return nil, fmt.Errorf("Mock transport error")
   234  				},
   235  			},
   236  		}
   237  		if os.Getenv("DEBUG") != "" {
   238  			config.Logger = &opensearchtransport.ColorLogger{
   239  				Output:             os.Stdout,
   240  				EnableRequestBody:  true,
   241  				EnableResponseBody: true,
   242  			}
   243  		}
   244  
   245  		client, _ := opensearch.NewClient(config)
   246  
   247  		var indexerError error
   248  		biCfg := BulkIndexerConfig{
   249  			NumWorkers: 1,
   250  			Client:     client,
   251  			OnError:    func(ctx context.Context, err error) { indexerError = err },
   252  		}
   253  		if os.Getenv("DEBUG") != "" {
   254  			biCfg.DebugLogger = log.New(os.Stdout, "", 0)
   255  		}
   256  
   257  		bi, _ := NewBulkIndexer(biCfg)
   258  
   259  		if err := bi.Add(context.Background(), BulkIndexerItem{
   260  			Action: "foo",
   261  		}); err != nil {
   262  			t.Fatalf("Unexpected error: %s", err)
   263  		}
   264  
   265  		bi.Close(context.Background())
   266  
   267  		if indexerError == nil {
   268  			t.Errorf("Expected indexerError to not be nil")
   269  		}
   270  	})
   271  
   272  	t.Run("Item Callbacks", func(t *testing.T) {
   273  		var (
   274  			countSuccessful      uint64
   275  			countFailed          uint64
   276  			failedIDs            []string
   277  			successfulItemBodies []string
   278  			failedItemBodies     []string
   279  
   280  			numItems       = 4
   281  			numFailed      = 2
   282  			bodyContent, _ = ioutil.ReadFile("testdata/bulk_response_2.json")
   283  		)
   284  
   285  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{
   286  			RoundTripFunc: func(request *http.Request) (*http.Response, error) {
   287  				if request.URL.Path == "/" {
   288  					return &http.Response{
   289  						StatusCode: http.StatusOK,
   290  						Status:     "200 OK",
   291  						Body:       ioutil.NopCloser(strings.NewReader(infoBody)),
   292  						Header:     http.Header{"Content-Type": []string{"application/json"}},
   293  					}, nil
   294  				}
   295  
   296  				return &http.Response{Body: ioutil.NopCloser(bytes.NewBuffer(bodyContent))}, nil
   297  			},
   298  		}})
   299  
   300  		cfg := BulkIndexerConfig{NumWorkers: 1, Client: client}
   301  		if os.Getenv("DEBUG") != "" {
   302  			cfg.DebugLogger = log.New(os.Stdout, "", 0)
   303  		}
   304  
   305  		bi, _ := NewBulkIndexer(cfg)
   306  
   307  		successFunc := func(ctx context.Context, item BulkIndexerItem, res BulkIndexerResponseItem) {
   308  			atomic.AddUint64(&countSuccessful, 1)
   309  
   310  			buf, err := ioutil.ReadAll(item.Body)
   311  			if err != nil {
   312  				t.Fatalf("Unexpected error: %s", err)
   313  			}
   314  			successfulItemBodies = append(successfulItemBodies, string(buf))
   315  		}
   316  		failureFunc := func(ctx context.Context, item BulkIndexerItem, res BulkIndexerResponseItem, err error) {
   317  			if err != nil {
   318  				t.Fatalf("Unexpected error: %s", err)
   319  			}
   320  			atomic.AddUint64(&countFailed, 1)
   321  			failedIDs = append(failedIDs, item.DocumentID)
   322  
   323  			buf, err := ioutil.ReadAll(item.Body)
   324  			if err != nil {
   325  				t.Fatalf("Unexpected error: %s", err)
   326  			}
   327  			failedItemBodies = append(failedItemBodies, string(buf))
   328  		}
   329  
   330  		if err := bi.Add(context.Background(), BulkIndexerItem{
   331  			Action:     "index",
   332  			DocumentID: "1",
   333  			Body:       strings.NewReader(`{"title":"foo"}`),
   334  			OnSuccess:  successFunc,
   335  			OnFailure:  failureFunc,
   336  		}); err != nil {
   337  			t.Fatalf("Unexpected error: %s", err)
   338  		}
   339  
   340  		if err := bi.Add(context.Background(), BulkIndexerItem{
   341  			Action:     "create",
   342  			DocumentID: "1",
   343  			Body:       strings.NewReader(`{"title":"bar"}`),
   344  			OnSuccess:  successFunc,
   345  			OnFailure:  failureFunc,
   346  		}); err != nil {
   347  			t.Fatalf("Unexpected error: %s", err)
   348  		}
   349  
   350  		if err := bi.Add(context.Background(), BulkIndexerItem{
   351  			Action:     "delete",
   352  			DocumentID: "2",
   353  			Body:       strings.NewReader(`{"title":"baz"}`),
   354  			OnSuccess:  successFunc,
   355  			OnFailure:  failureFunc,
   356  		}); err != nil {
   357  			t.Fatalf("Unexpected error: %s", err)
   358  		}
   359  
   360  		if err := bi.Add(context.Background(), BulkIndexerItem{
   361  			Action:     "update",
   362  			DocumentID: "3",
   363  			Body:       strings.NewReader(`{"doc":{"title":"qux"}}`),
   364  			OnSuccess:  successFunc,
   365  			OnFailure:  failureFunc,
   366  		}); err != nil {
   367  			t.Fatalf("Unexpected error: %s", err)
   368  		}
   369  
   370  		if err := bi.Close(context.Background()); err != nil {
   371  			t.Errorf("Unexpected error: %s", err)
   372  		}
   373  
   374  		stats := bi.Stats()
   375  
   376  		if stats.NumAdded != uint64(numItems) {
   377  			t.Errorf("Unexpected NumAdded: %d", stats.NumAdded)
   378  		}
   379  
   380  		// Two failures are expected:
   381  		//
   382  		// * Operation #2: document can't be created, because a document with the same ID already exists.
   383  		// * Operation #3: document can't be deleted, because it doesn't exist.
   384  
   385  		if stats.NumFailed != uint64(numFailed) {
   386  			t.Errorf("Unexpected NumFailed: %d", stats.NumFailed)
   387  		}
   388  
   389  		if stats.NumFlushed != 2 {
   390  			t.Errorf("Unexpected NumFailed: %d", stats.NumFailed)
   391  		}
   392  
   393  		if stats.NumIndexed != 1 {
   394  			t.Errorf("Unexpected NumIndexed: %d", stats.NumIndexed)
   395  		}
   396  
   397  		if stats.NumUpdated != 1 {
   398  			t.Errorf("Unexpected NumUpdated: %d", stats.NumUpdated)
   399  		}
   400  
   401  		if countSuccessful != uint64(numItems-numFailed) {
   402  			t.Errorf("Unexpected countSuccessful: %d", countSuccessful)
   403  		}
   404  
   405  		if countFailed != uint64(numFailed) {
   406  			t.Errorf("Unexpected countFailed: %d", countFailed)
   407  		}
   408  
   409  		if !reflect.DeepEqual(failedIDs, []string{"1", "2"}) {
   410  			t.Errorf("Unexpected failedIDs: %#v", failedIDs)
   411  		}
   412  
   413  		if !reflect.DeepEqual(successfulItemBodies, []string{`{"title":"foo"}`, `{"doc":{"title":"qux"}}`}) {
   414  			t.Errorf("Unexpected successfulItemBodies: %#v", successfulItemBodies)
   415  		}
   416  
   417  		if !reflect.DeepEqual(failedItemBodies, []string{`{"title":"bar"}`, `{"title":"baz"}`}) {
   418  			t.Errorf("Unexpected failedItemBodies: %#v", failedItemBodies)
   419  		}
   420  	})
   421  
   422  	t.Run("OnFlush callbacks", func(t *testing.T) {
   423  		type contextKey string
   424  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{}})
   425  		bi, _ := NewBulkIndexer(BulkIndexerConfig{
   426  			Client: client,
   427  			Index:  "foo",
   428  			OnFlushStart: func(ctx context.Context) context.Context {
   429  				fmt.Println(">>> Flush started")
   430  				return context.WithValue(ctx, contextKey("start"), time.Now().UTC())
   431  			},
   432  			OnFlushEnd: func(ctx context.Context) {
   433  				var duration time.Duration
   434  				if v := ctx.Value("start"); v != nil {
   435  					duration = time.Since(v.(time.Time))
   436  				}
   437  				fmt.Printf(">>> Flush finished (duration: %s)\n", duration)
   438  			},
   439  		})
   440  
   441  		err := bi.Add(context.Background(), BulkIndexerItem{
   442  			Action: "index",
   443  			Body:   strings.NewReader(`{"title":"foo"}`),
   444  		})
   445  		if err != nil {
   446  			t.Fatalf("Unexpected error: %s", err)
   447  		}
   448  
   449  		if err := bi.Close(context.Background()); err != nil {
   450  			t.Errorf("Unexpected error: %s", err)
   451  		}
   452  
   453  		stats := bi.Stats()
   454  
   455  		if stats.NumAdded != uint64(1) {
   456  			t.Errorf("Unexpected NumAdded: %d", stats.NumAdded)
   457  		}
   458  	})
   459  
   460  	t.Run("Automatic flush", func(t *testing.T) {
   461  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{
   462  			RoundTripFunc: func(request *http.Request) (*http.Response, error) {
   463  				if request.URL.Path == "/" {
   464  					return &http.Response{
   465  						StatusCode: http.StatusOK,
   466  						Status:     "200 OK",
   467  						Body:       ioutil.NopCloser(strings.NewReader(infoBody)),
   468  						Header:     http.Header{"Content-Type": []string{"application/json"}},
   469  					}, nil
   470  				}
   471  
   472  				return &http.Response{
   473  					StatusCode: http.StatusOK,
   474  					Status:     "200 OK",
   475  					Body:       ioutil.NopCloser(strings.NewReader(`{"items":[{"index": {}}]}`))}, nil
   476  			},
   477  		}})
   478  
   479  		cfg := BulkIndexerConfig{
   480  			NumWorkers:    1,
   481  			Client:        client,
   482  			FlushInterval: 50 * time.Millisecond, // Decrease the flush timeout
   483  		}
   484  		if os.Getenv("DEBUG") != "" {
   485  			cfg.DebugLogger = log.New(os.Stdout, "", 0)
   486  		}
   487  
   488  		bi, _ := NewBulkIndexer(cfg)
   489  
   490  		bi.Add(context.Background(),
   491  			BulkIndexerItem{Action: "index", Body: strings.NewReader(`{"title":"foo"}`)})
   492  
   493  		// Allow some time for auto-flush to kick in
   494  		time.Sleep(250 * time.Millisecond)
   495  
   496  		stats := bi.Stats()
   497  		expected := uint64(1)
   498  
   499  		if stats.NumAdded != expected {
   500  			t.Errorf("Unexpected NumAdded: want=%d, got=%d", expected, stats.NumAdded)
   501  		}
   502  
   503  		if stats.NumFailed != 0 {
   504  			t.Errorf("Unexpected NumFailed: want=%d, got=%d", 0, stats.NumFlushed)
   505  		}
   506  
   507  		if stats.NumFlushed != expected {
   508  			t.Errorf("Unexpected NumFlushed: want=%d, got=%d", expected, stats.NumFlushed)
   509  		}
   510  
   511  		if stats.NumIndexed != expected {
   512  			t.Errorf("Unexpected NumIndexed: want=%d, got=%d", expected, stats.NumIndexed)
   513  		}
   514  
   515  		// Wait some time before closing the indexer to clear the timer
   516  		time.Sleep(200 * time.Millisecond)
   517  		bi.Close(context.Background())
   518  	})
   519  
   520  	t.Run("TooManyRequests", func(t *testing.T) {
   521  		var (
   522  			wg sync.WaitGroup
   523  
   524  			countReqs int
   525  			numItems  = 2
   526  		)
   527  
   528  		cfg := opensearch.Config{
   529  			Transport: &mockTransport{
   530  				RoundTripFunc: func(request *http.Request) (*http.Response, error) {
   531  					if request.URL.Path == "/" {
   532  						return &http.Response{
   533  							StatusCode: http.StatusOK,
   534  							Status:     "200 OK",
   535  							Body:       ioutil.NopCloser(strings.NewReader(infoBody)),
   536  							Header:     http.Header{"Content-Type": []string{"application/json"}},
   537  						}, nil
   538  					}
   539  
   540  					countReqs++
   541  					if countReqs <= 4 {
   542  						return &http.Response{
   543  							StatusCode: http.StatusTooManyRequests,
   544  							Status:     "429 TooManyRequests",
   545  							Body:       ioutil.NopCloser(strings.NewReader(`{"took":1}`))}, nil
   546  					}
   547  					bodyContent, _ := ioutil.ReadFile("testdata/bulk_response_1c.json")
   548  					return &http.Response{
   549  						StatusCode: http.StatusOK,
   550  						Status:     "200 OK",
   551  						Body:       ioutil.NopCloser(bytes.NewBuffer(bodyContent)),
   552  					}, nil
   553  				},
   554  			},
   555  
   556  			MaxRetries:    5,
   557  			RetryOnStatus: []int{502, 503, 504, 429},
   558  			RetryBackoff: func(i int) time.Duration {
   559  				if os.Getenv("DEBUG") != "" {
   560  					fmt.Printf("*** Retry #%d\n", i)
   561  				}
   562  				return time.Duration(i) * 100 * time.Millisecond
   563  			},
   564  		}
   565  		if os.Getenv("DEBUG") != "" {
   566  			cfg.Logger = &opensearchtransport.ColorLogger{Output: os.Stdout}
   567  		}
   568  		client, _ := opensearch.NewClient(cfg)
   569  
   570  		biCfg := BulkIndexerConfig{NumWorkers: 1, FlushBytes: 50, Client: client}
   571  		if os.Getenv("DEBUG") != "" {
   572  			biCfg.DebugLogger = log.New(os.Stdout, "", 0)
   573  		}
   574  
   575  		bi, _ := NewBulkIndexer(biCfg)
   576  
   577  		for i := 1; i <= numItems; i++ {
   578  			wg.Add(1)
   579  			go func(i int) {
   580  				defer wg.Done()
   581  				err := bi.Add(context.Background(), BulkIndexerItem{
   582  					Action: "foo",
   583  					Body:   strings.NewReader(`{"title":"foo"}`),
   584  				})
   585  				if err != nil {
   586  					t.Errorf("Unexpected error: %s", err)
   587  					return
   588  				}
   589  			}(i)
   590  		}
   591  		wg.Wait()
   592  
   593  		if err := bi.Close(context.Background()); err != nil {
   594  			t.Errorf("Unexpected error: %s", err)
   595  		}
   596  
   597  		stats := bi.Stats()
   598  
   599  		if stats.NumAdded != uint64(numItems) {
   600  			t.Errorf("Unexpected NumAdded: want=%d, got=%d", numItems, stats.NumAdded)
   601  		}
   602  
   603  		if stats.NumFlushed != uint64(numItems) {
   604  			t.Errorf("Unexpected NumFlushed: want=%d, got=%d", numItems, stats.NumFlushed)
   605  		}
   606  
   607  		if stats.NumFailed != 0 {
   608  			t.Errorf("Unexpected NumFailed: want=%d, got=%d", 0, stats.NumFailed)
   609  		}
   610  
   611  		// Stats don't include the retries in client
   612  		if stats.NumRequests != 1 {
   613  			t.Errorf("Unexpected NumRequests: want=%d, got=%d", 3, stats.NumRequests)
   614  		}
   615  	})
   616  
   617  	t.Run("Custom JSON Decoder", func(t *testing.T) {
   618  		client, _ := opensearch.NewClient(opensearch.Config{Transport: &mockTransport{}})
   619  		bi, _ := NewBulkIndexer(BulkIndexerConfig{Client: client, Decoder: customJSONDecoder{}})
   620  
   621  		err := bi.Add(context.Background(), BulkIndexerItem{
   622  			Action:     "index",
   623  			DocumentID: "1",
   624  			Body:       strings.NewReader(`{"title":"foo"}`),
   625  		})
   626  		if err != nil {
   627  			t.Fatalf("Unexpected error: %s", err)
   628  		}
   629  
   630  		if err := bi.Close(context.Background()); err != nil {
   631  			t.Errorf("Unexpected error: %s", err)
   632  		}
   633  
   634  		stats := bi.Stats()
   635  
   636  		if stats.NumAdded != uint64(1) {
   637  			t.Errorf("Unexpected NumAdded: %d", stats.NumAdded)
   638  		}
   639  	})
   640  
   641  	t.Run("Worker.writeMeta()", func(t *testing.T) {
   642  		type args struct {
   643  			item BulkIndexerItem
   644  		}
   645  		tests := []struct {
   646  			name string
   647  			args args
   648  			want string
   649  		}{
   650  			{
   651  				"without _index and _id",
   652  				args{BulkIndexerItem{Action: "index"}},
   653  				`{"index":{}}` + "\n",
   654  			},
   655  			{
   656  				"with _id",
   657  				args{BulkIndexerItem{
   658  					Action:     "index",
   659  					DocumentID: "42",
   660  				}},
   661  				`{"index":{"_id":"42"}}` + "\n",
   662  			},
   663  			{
   664  				"with _index",
   665  				args{BulkIndexerItem{
   666  					Action: "index",
   667  					Index:  "test",
   668  				}},
   669  				`{"index":{"_index":"test"}}` + "\n",
   670  			},
   671  			{
   672  				"with _index and _id",
   673  				args{BulkIndexerItem{
   674  					Action:     "index",
   675  					DocumentID: "42",
   676  					Index:      "test",
   677  				}},
   678  				`{"index":{"_index":"test","_id":"42"}}` + "\n",
   679  			},
   680  			{
   681  				"with if_seq_no and if_primary_term",
   682  				args{BulkIndexerItem{
   683  					Action:        "index",
   684  					DocumentID:    "42",
   685  					Index:         "test",
   686  					IfSeqNum:      int64Pointer(5),
   687  					IfPrimaryTerm: int64Pointer(1),
   688  				}},
   689  				`{"index":{"_index":"test","_id":"42","if_seq_no":5,"if_primary_term":1}}` + "\n",
   690  			},
   691  			{
   692  				"with version and no document, if_seq_no, and if_primary_term",
   693  				args{BulkIndexerItem{
   694  					Action:  "index",
   695  					Index:   "test",
   696  					Version: int64Pointer(23),
   697  				}},
   698  				`{"index":{"_index":"test"}}` + "\n",
   699  			},
   700  			{
   701  				"with version",
   702  				args{BulkIndexerItem{
   703  					Action:     "index",
   704  					DocumentID: "42",
   705  					Index:      "test",
   706  					Version:    int64Pointer(24),
   707  				}},
   708  				`{"index":{"_index":"test","_id":"42","version":24}}` + "\n",
   709  			},
   710  			{
   711  				"with version and version_type",
   712  				args{BulkIndexerItem{
   713  					Action:      "index",
   714  					DocumentID:  "42",
   715  					Index:       "test",
   716  					Version:     int64Pointer(25),
   717  					VersionType: strPointer("external"),
   718  				}},
   719  				`{"index":{"_index":"test","_id":"42","version":25,"version_type":"external"}}` + "\n",
   720  			},
   721  			{
   722  				"wait_for_active_shards",
   723  				args{BulkIndexerItem{
   724  					Action:              "index",
   725  					DocumentID:          "42",
   726  					Index:               "test",
   727  					Version:             int64Pointer(25),
   728  					VersionType:         strPointer("external"),
   729  					WaitForActiveShards: 1,
   730  				}},
   731  				`{"index":{"_index":"test","_id":"42","version":25,"version_type":"external","wait_for_active_shards":1}}` + "\n",
   732  			},
   733  			{
   734  				"wait_for_active_shards, all",
   735  				args{BulkIndexerItem{
   736  					Action:              "index",
   737  					DocumentID:          "42",
   738  					Index:               "test",
   739  					Version:             int64Pointer(25),
   740  					VersionType:         strPointer("external"),
   741  					WaitForActiveShards: "all",
   742  				}},
   743  				`{"index":{"_index":"test","_id":"42","version":25,"version_type":"external","wait_for_active_shards":"all"}}` + "\n",
   744  			},
   745  			{
   746  				"with retry_on_conflict",
   747  				args{BulkIndexerItem{
   748  					Action:          "index",
   749  					DocumentID:      "42",
   750  					Index:           "test",
   751  					Version:         int64Pointer(25),
   752  					VersionType:     strPointer("external"),
   753  					RetryOnConflict: intPointer(5),
   754  				}},
   755  				`{"index":{"_index":"test","_id":"42","version":25,"version_type":"external","retry_on_conflict":5}}` + "\n",
   756  			},
   757  		}
   758  		for _, tt := range tests {
   759  			tt := tt
   760  
   761  			t.Run(tt.name, func(t *testing.T) {
   762  				w := &worker{
   763  					buf: bytes.NewBuffer(make([]byte, 0, 5e+6)),
   764  					aux: make([]byte, 0, 512),
   765  				}
   766  				if err := w.writeMeta(tt.args.item); err != nil {
   767  					t.Errorf("Unexpected error: %v", err)
   768  				}
   769  
   770  				if w.buf.String() != tt.want {
   771  					t.Errorf("worker.writeMeta() %s = got [%s], want [%s]", tt.name, w.buf.String(), tt.want)
   772  				}
   773  
   774  			})
   775  		}
   776  	})
   777  }
   778  
   779  type customJSONDecoder struct{}
   780  
   781  func (d customJSONDecoder) UnmarshalFromReader(r io.Reader, blk *BulkIndexerResponse) error {
   782  	return json.NewDecoder(r).Decode(blk)
   783  }
   784  
   785  func strPointer(s string) *string {
   786  	return &s
   787  }
   788  
   789  func int64Pointer(i int64) *int64 {
   790  	return &i
   791  }
   792  
   793  func intPointer(i int) *int {
   794  	return &i
   795  }