github.com/influxdata/influxdb/v2@v2.7.6/replications/remotewrite/writer_test.go (about)

     1  package remotewrite
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"strconv"
    11  	"strings"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/golang/mock/gomock"
    16  	"github.com/influxdata/influxdb/v2"
    17  	"github.com/influxdata/influxdb/v2/kit/platform"
    18  	errors2 "github.com/influxdata/influxdb/v2/kit/platform/errors"
    19  	"github.com/influxdata/influxdb/v2/kit/prom"
    20  	"github.com/influxdata/influxdb/v2/kit/prom/promtest"
    21  	ihttp "github.com/influxdata/influxdb/v2/kit/transport/http"
    22  	"github.com/influxdata/influxdb/v2/replications/metrics"
    23  	replicationsMock "github.com/influxdata/influxdb/v2/replications/mock"
    24  	"github.com/stretchr/testify/require"
    25  	"go.uber.org/zap/zaptest"
    26  )
    27  
    28  //go:generate go run github.com/golang/mock/mockgen -package mock -destination ../mock/http_config_store.go github.com/influxdata/influxdb/v2/replications/remotewrite HttpConfigStore
    29  
    30  var (
    31  	testID = platform.ID(1)
    32  )
    33  
    34  func testWriter(t *testing.T) (*writer, *replicationsMock.MockHttpConfigStore, chan struct{}) {
    35  	ctrl := gomock.NewController(t)
    36  	configStore := replicationsMock.NewMockHttpConfigStore(ctrl)
    37  	done := make(chan struct{})
    38  	w := NewWriter(testID, configStore, metrics.NewReplicationsMetrics(), zaptest.NewLogger(t), done)
    39  	return w, configStore, done
    40  }
    41  
    42  func constantStatus(i int) func(int) int {
    43  	return func(int) int {
    44  		return i
    45  	}
    46  }
    47  
    48  func testServer(t *testing.T, statusForCount func(int) int, wantData []byte) *httptest.Server {
    49  	count := 0
    50  	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    51  		gotData, err := io.ReadAll(r.Body)
    52  		require.NoError(t, err)
    53  		require.Equal(t, wantData, gotData)
    54  		w.WriteHeader(statusForCount(count))
    55  		count++
    56  	}))
    57  }
    58  
    59  func instaWait() waitFunc {
    60  	return func(t time.Duration) <-chan time.Time {
    61  		out := make(chan time.Time)
    62  		close(out)
    63  		return out
    64  	}
    65  }
    66  
    67  type containsMatcher struct {
    68  	substring string
    69  }
    70  
    71  func (cm *containsMatcher) Matches(x interface{}) bool {
    72  	if st, ok := x.(fmt.Stringer); ok {
    73  		return strings.Contains(st.String(), cm.substring)
    74  	} else {
    75  		s, ok := x.(string)
    76  		return ok && strings.Contains(s, cm.substring)
    77  	}
    78  }
    79  
    80  func (cm *containsMatcher) String() string {
    81  	if cm != nil {
    82  		return cm.substring
    83  	} else {
    84  		return ""
    85  	}
    86  }
    87  
    88  func TestWrite(t *testing.T) {
    89  	t.Parallel()
    90  
    91  	testData := []byte("some data")
    92  
    93  	t.Run("error getting config", func(t *testing.T) {
    94  		wantErr := errors.New("uh oh")
    95  
    96  		w, configStore, _ := testWriter(t)
    97  
    98  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(nil, wantErr)
    99  		_, actualErr := w.Write([]byte{}, 1)
   100  		require.Equal(t, wantErr, actualErr)
   101  	})
   102  
   103  	t.Run("nil response from PostWrite", func(t *testing.T) {
   104  		testConfig := &influxdb.ReplicationHTTPConfig{
   105  			RemoteURL: "not a good URL",
   106  		}
   107  		w, configStore, _ := testWriter(t)
   108  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   109  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, int(0), gomock.Any())
   110  		_, actualErr := w.Write([]byte{}, 1)
   111  		require.Error(t, actualErr)
   112  	})
   113  
   114  	t.Run("immediate good response", func(t *testing.T) {
   115  		svr := testServer(t, constantStatus(http.StatusNoContent), testData)
   116  		defer svr.Close()
   117  
   118  		testConfig := &influxdb.ReplicationHTTPConfig{
   119  			RemoteURL: svr.URL,
   120  		}
   121  
   122  		w, configStore, _ := testWriter(t)
   123  
   124  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   125  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusNoContent, "").Return(nil)
   126  		_, actualErr := w.Write(testData, 0)
   127  		require.NoError(t, actualErr)
   128  	})
   129  
   130  	t.Run("error updating response info", func(t *testing.T) {
   131  		wantErr := errors.New("o no")
   132  
   133  		svr := testServer(t, constantStatus(http.StatusNoContent), testData)
   134  		defer svr.Close()
   135  
   136  		testConfig := &influxdb.ReplicationHTTPConfig{
   137  			RemoteURL: svr.URL,
   138  		}
   139  
   140  		w, configStore, _ := testWriter(t)
   141  
   142  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   143  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusNoContent, "").Return(wantErr)
   144  		_, actualErr := w.Write(testData, 1)
   145  		require.Equal(t, wantErr, actualErr)
   146  	})
   147  
   148  	t.Run("bad server responses that never succeed", func(t *testing.T) {
   149  		testAttempts := 3
   150  
   151  		for _, status := range []int{http.StatusOK, http.StatusTeapot, http.StatusInternalServerError} {
   152  			t.Run(fmt.Sprintf("status code %d", status), func(t *testing.T) {
   153  				svr := testServer(t, constantStatus(status), testData)
   154  				defer svr.Close()
   155  
   156  				testConfig := &influxdb.ReplicationHTTPConfig{
   157  					RemoteURL: svr.URL,
   158  				}
   159  
   160  				w, configStore, _ := testWriter(t)
   161  				w.waitFunc = instaWait()
   162  
   163  				configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   164  				configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, status, &containsMatcher{invalidResponseCode(status, nil).Error()}).Return(nil)
   165  				_, actualErr := w.Write(testData, testAttempts)
   166  				require.NotNil(t, actualErr)
   167  				require.Contains(t, actualErr.Error(), fmt.Sprintf("invalid response code %d", status))
   168  			})
   169  		}
   170  	})
   171  
   172  	t.Run("drops bad data after config is updated", func(t *testing.T) {
   173  		testAttempts := 5
   174  
   175  		svr := testServer(t, constantStatus(http.StatusBadRequest), testData)
   176  		defer svr.Close()
   177  
   178  		testConfig := &influxdb.ReplicationHTTPConfig{
   179  			RemoteURL: svr.URL,
   180  		}
   181  
   182  		updatedConfig := &influxdb.ReplicationHTTPConfig{
   183  			RemoteURL:            svr.URL,
   184  			DropNonRetryableData: true,
   185  		}
   186  
   187  		w, configStore, _ := testWriter(t)
   188  		w.waitFunc = instaWait()
   189  
   190  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil).Times(testAttempts - 1)
   191  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(updatedConfig, nil)
   192  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusBadRequest, &containsMatcher{invalidResponseCode(http.StatusBadRequest, nil).Error()}).Return(nil).Times(testAttempts)
   193  		for i := 1; i <= testAttempts; i++ {
   194  			_, actualErr := w.Write(testData, i)
   195  			if testAttempts == i {
   196  				require.NoError(t, actualErr)
   197  			} else {
   198  				require.Error(t, actualErr)
   199  			}
   200  		}
   201  	})
   202  
   203  	t.Run("gives backoff time on write response", func(t *testing.T) {
   204  		svr := testServer(t, constantStatus(http.StatusBadRequest), testData)
   205  		defer svr.Close()
   206  
   207  		testConfig := &influxdb.ReplicationHTTPConfig{
   208  			RemoteURL: svr.URL,
   209  		}
   210  
   211  		w, configStore, _ := testWriter(t)
   212  
   213  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   214  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusBadRequest, gomock.Any()).Return(nil)
   215  		backoff, actualErr := w.Write(testData, 1)
   216  		require.Equal(t, backoff, w.backoff(1))
   217  		require.ErrorContains(t, actualErr, invalidResponseCode(http.StatusBadRequest, nil).Error())
   218  	})
   219  
   220  	t.Run("uses wait time from response header if present", func(t *testing.T) {
   221  		numSeconds := 5
   222  		waitTimeFromHeader := 5 * time.Second
   223  
   224  		svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   225  			gotData, err := io.ReadAll(r.Body)
   226  			require.NoError(t, err)
   227  			require.Equal(t, testData, gotData)
   228  			w.Header().Set(retryAfterHeaderKey, strconv.Itoa(numSeconds))
   229  			w.WriteHeader(http.StatusTooManyRequests)
   230  		}))
   231  		defer svr.Close()
   232  
   233  		testConfig := &influxdb.ReplicationHTTPConfig{
   234  			RemoteURL: svr.URL,
   235  		}
   236  
   237  		w, configStore, done := testWriter(t)
   238  		w.waitFunc = func(dur time.Duration) <-chan time.Time {
   239  			require.Equal(t, waitTimeFromHeader, dur)
   240  			close(done)
   241  			return instaWait()(dur)
   242  		}
   243  
   244  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   245  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusTooManyRequests, &containsMatcher{invalidResponseCode(http.StatusTooManyRequests, nil).Error()}).Return(nil)
   246  		_, actualErr := w.Write(testData, 1)
   247  		require.ErrorContains(t, actualErr, invalidResponseCode(http.StatusTooManyRequests, nil).Error())
   248  	})
   249  
   250  	t.Run("can cancel with done channel", func(t *testing.T) {
   251  		svr := testServer(t, constantStatus(http.StatusInternalServerError), testData)
   252  		defer svr.Close()
   253  
   254  		testConfig := &influxdb.ReplicationHTTPConfig{
   255  			RemoteURL: svr.URL,
   256  		}
   257  
   258  		w, configStore, _ := testWriter(t)
   259  
   260  		configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   261  		configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusInternalServerError, &containsMatcher{invalidResponseCode(http.StatusInternalServerError, nil).Error()}).Return(nil)
   262  		_, actualErr := w.Write(testData, 1)
   263  		require.ErrorContains(t, actualErr, invalidResponseCode(http.StatusInternalServerError, nil).Error())
   264  	})
   265  
   266  	t.Run("writes resume after temporary remote disconnect", func(t *testing.T) {
   267  		// Attempt to write data a total of 5 times.
   268  		// Succeed on the first point, writing point 1. (baseline test)
   269  		// Fail on the second and third, then succeed on the fourth, writing point 2.
   270  		// Fail on the fifth, sixth and seventh, then succeed on the eighth, writing point 3.
   271  		attemptMap := make([]bool, 8)
   272  		attemptMap[0] = true
   273  		attemptMap[3] = true
   274  		attemptMap[7] = true
   275  		var attempt uint8
   276  
   277  		var currentWrite int
   278  		testWrites := []string{
   279  			"this is some data",
   280  			"this is also some data",
   281  			"this is even more data",
   282  		}
   283  
   284  		svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   285  			if attemptMap[attempt] {
   286  				gotData, err := io.ReadAll(r.Body)
   287  				require.NoError(t, err)
   288  				require.Equal(t, []byte(testWrites[currentWrite]), gotData)
   289  				w.WriteHeader(http.StatusNoContent)
   290  			} else {
   291  				// Simulate a timeout, as if the remote connection were offline
   292  				w.WriteHeader(http.StatusGatewayTimeout)
   293  			}
   294  			attempt++
   295  		}))
   296  		defer svr.Close()
   297  
   298  		testConfig := &influxdb.ReplicationHTTPConfig{
   299  			RemoteURL: svr.URL,
   300  		}
   301  		w, configStore, _ := testWriter(t)
   302  
   303  		numAttempts := 0
   304  		for i := 0; i < len(testWrites); i++ {
   305  			currentWrite = i
   306  			configStore.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(testConfig, nil)
   307  			if attemptMap[attempt] {
   308  				// should succeed
   309  				configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusNoContent, gomock.Any()).Return(nil)
   310  				_, err := w.Write([]byte(testWrites[i]), numAttempts)
   311  				require.NoError(t, err)
   312  				numAttempts = 0
   313  			} else {
   314  				// should fail
   315  				configStore.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusGatewayTimeout, &containsMatcher{invalidResponseCode(http.StatusGatewayTimeout, nil).Error()}).Return(nil)
   316  				_, err := w.Write([]byte(testWrites[i]), numAttempts)
   317  				require.Error(t, err)
   318  				numAttempts++
   319  				i-- // decrement so that we retry this same data point in the next loop iteration
   320  			}
   321  		}
   322  	})
   323  }
   324  
   325  func TestWrite_Metrics(t *testing.T) {
   326  	testData := []byte("this is some data")
   327  
   328  	tests := []struct {
   329  		name                 string
   330  		status               func(int) int
   331  		expectedErr          error
   332  		data                 []byte
   333  		registerExpectations func(*testing.T, *replicationsMock.MockHttpConfigStore, *influxdb.ReplicationHTTPConfig)
   334  		checkMetrics         func(*testing.T, *prom.Registry)
   335  	}{
   336  		{
   337  			name:        "server errors",
   338  			status:      constantStatus(http.StatusTeapot),
   339  			expectedErr: invalidResponseCode(http.StatusTeapot, nil),
   340  			data:        []byte{},
   341  			registerExpectations: func(t *testing.T, store *replicationsMock.MockHttpConfigStore, conf *influxdb.ReplicationHTTPConfig) {
   342  				store.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(conf, nil)
   343  				store.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusTeapot, &containsMatcher{invalidResponseCode(http.StatusTeapot, nil).Error()}).Return(nil)
   344  			},
   345  			checkMetrics: func(t *testing.T, reg *prom.Registry) {
   346  				mfs := promtest.MustGather(t, reg)
   347  				errorCodes := promtest.FindMetric(mfs, "replications_queue_remote_write_errors", map[string]string{
   348  					"replicationID": testID.String(),
   349  					"code":          strconv.Itoa(http.StatusTeapot),
   350  				})
   351  				require.NotNil(t, errorCodes)
   352  			},
   353  		},
   354  		{
   355  			name:   "successful write",
   356  			status: constantStatus(http.StatusNoContent),
   357  			data:   testData,
   358  			registerExpectations: func(t *testing.T, store *replicationsMock.MockHttpConfigStore, conf *influxdb.ReplicationHTTPConfig) {
   359  				store.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(conf, nil)
   360  				store.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusNoContent, "").Return(nil)
   361  			},
   362  			checkMetrics: func(t *testing.T, reg *prom.Registry) {
   363  				mfs := promtest.MustGather(t, reg)
   364  
   365  				bytesSent := promtest.FindMetric(mfs, "replications_queue_remote_write_bytes_sent", map[string]string{
   366  					"replicationID": testID.String(),
   367  				})
   368  				require.NotNil(t, bytesSent)
   369  				require.Equal(t, float64(len(testData)), bytesSent.Counter.GetValue())
   370  			},
   371  		},
   372  		{
   373  			name:   "dropped data",
   374  			status: constantStatus(http.StatusBadRequest),
   375  			data:   testData,
   376  			registerExpectations: func(t *testing.T, store *replicationsMock.MockHttpConfigStore, conf *influxdb.ReplicationHTTPConfig) {
   377  				store.EXPECT().GetFullHTTPConfig(gomock.Any(), testID).Return(conf, nil)
   378  				store.EXPECT().UpdateResponseInfo(gomock.Any(), testID, http.StatusBadRequest, &containsMatcher{invalidResponseCode(http.StatusBadRequest, nil).Error()}).Return(nil)
   379  			},
   380  			checkMetrics: func(t *testing.T, reg *prom.Registry) {
   381  				mfs := promtest.MustGather(t, reg)
   382  
   383  				bytesDropped := promtest.FindMetric(mfs, "replications_queue_remote_write_bytes_dropped", map[string]string{
   384  					"replicationID": testID.String(),
   385  				})
   386  				require.NotNil(t, bytesDropped)
   387  				require.Equal(t, float64(len(testData)), bytesDropped.Counter.GetValue())
   388  			},
   389  		},
   390  	}
   391  
   392  	for _, tt := range tests {
   393  		t.Run(tt.name, func(t *testing.T) {
   394  			svr := testServer(t, tt.status, tt.data)
   395  			defer svr.Close()
   396  
   397  			testConfig := &influxdb.ReplicationHTTPConfig{
   398  				RemoteURL:            svr.URL,
   399  				DropNonRetryableData: true,
   400  			}
   401  
   402  			w, configStore, _ := testWriter(t)
   403  			w.waitFunc = instaWait()
   404  			reg := prom.NewRegistry(zaptest.NewLogger(t))
   405  			reg.MustRegister(w.metrics.PrometheusCollectors()...)
   406  
   407  			tt.registerExpectations(t, configStore, testConfig)
   408  			_, actualErr := w.Write(tt.data, 1)
   409  			if tt.expectedErr != nil {
   410  				require.ErrorContains(t, actualErr, tt.expectedErr.Error())
   411  			} else {
   412  				require.NoError(t, actualErr)
   413  			}
   414  			tt.checkMetrics(t, reg)
   415  		})
   416  	}
   417  }
   418  
   419  func TestPostWrite(t *testing.T) {
   420  	testData := []byte("some data")
   421  
   422  	tests := []struct {
   423  		status    int
   424  		influxErr string
   425  		bodyErr   error
   426  		wantErr   bool
   427  	}{
   428  		{
   429  			status:  http.StatusOK,
   430  			wantErr: true,
   431  		},
   432  		{
   433  			status:  http.StatusNoContent,
   434  			wantErr: false,
   435  		},
   436  		{
   437  			status:    http.StatusBadRequest,
   438  			influxErr: errors2.EEmptyValue,
   439  			wantErr:   true,
   440  			bodyErr:   fmt.Errorf("This is a terrible error: %w", errors.New("there are bad things here")),
   441  		},
   442  		{
   443  			status:    http.StatusMethodNotAllowed,
   444  			influxErr: errors2.EMethodNotAllowed,
   445  			wantErr:   true,
   446  			bodyErr:   fmt.Errorf("method not allowed: %w", errors.New("what were you thinking")),
   447  		},
   448  	}
   449  
   450  	for _, tt := range tests {
   451  		t.Run(fmt.Sprintf("status code %d", tt.status), func(t *testing.T) {
   452  			svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   453  				recData, err := io.ReadAll(r.Body)
   454  				require.NoError(t, err)
   455  				require.Equal(t, testData, recData)
   456  
   457  				if tt.bodyErr != nil {
   458  					ihttp.WriteErrorResponse(context.Background(), w, tt.influxErr, tt.bodyErr.Error())
   459  				} else {
   460  					w.WriteHeader(tt.status)
   461  				}
   462  			}))
   463  			defer svr.Close()
   464  
   465  			config := &influxdb.ReplicationHTTPConfig{
   466  				RemoteURL: svr.URL,
   467  			}
   468  
   469  			res, err := PostWrite(context.Background(), config, testData, time.Second)
   470  			if tt.wantErr {
   471  				require.Error(t, err)
   472  				if nil != tt.bodyErr {
   473  					require.ErrorContains(t, err, tt.bodyErr.Error())
   474  				}
   475  			} else {
   476  				require.Nil(t, err)
   477  			}
   478  
   479  			if res != nil {
   480  				require.Equal(t, tt.status, res.StatusCode)
   481  			}
   482  		})
   483  	}
   484  }
   485  
   486  func TestWaitTimeFromHeader(t *testing.T) {
   487  	w := &writer{
   488  		maximumAttemptsForBackoffTime: maximumAttempts,
   489  	}
   490  
   491  	tests := []struct {
   492  		headerKey string
   493  		headerVal string
   494  		want      time.Duration
   495  	}{
   496  		{
   497  			headerKey: retryAfterHeaderKey,
   498  			headerVal: "30",
   499  			want:      30 * time.Second,
   500  		},
   501  		{
   502  			headerKey: retryAfterHeaderKey,
   503  			headerVal: "0",
   504  			want:      w.backoff(1),
   505  		},
   506  		{
   507  			headerKey: retryAfterHeaderKey,
   508  			headerVal: "not a number",
   509  			want:      0,
   510  		},
   511  		{
   512  			headerKey: "some other thing",
   513  			headerVal: "not a number",
   514  			want:      0,
   515  		},
   516  	}
   517  
   518  	for _, tt := range tests {
   519  		t.Run(fmt.Sprintf("%q - %q", tt.headerKey, tt.headerVal), func(t *testing.T) {
   520  			r := &http.Response{
   521  				Header: http.Header{
   522  					tt.headerKey: []string{tt.headerVal},
   523  				},
   524  			}
   525  
   526  			got := w.waitTimeFromHeader(r)
   527  			require.Equal(t, tt.want, got)
   528  		})
   529  	}
   530  }