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 }