-
Notifications
You must be signed in to change notification settings - Fork 499
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* tools/goreplay-middleware: Add goreplay middleware * Fix linter errors --------- Co-authored-by: Bartek Nowotarski <bartek@nowotarski.info>
- Loading branch information
Showing
9 changed files
with
445 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# goreplay-middleware |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
// The code below is a goreplay middleware used for regression testing current | ||
// vs next Horizon version. The middleware system of goreplay is rather simple: | ||
// it streams one of 3 message types to stdin: request (HTTP headers), | ||
// original response and replayed response. On request we can modify the request | ||
// and send it to stdout but we don't use this feature here: we send request | ||
// to mirroring target as is. Finally, everything printed to stderr is the | ||
// middleware log, this is where we put the information about the request if the | ||
// diff is found. | ||
// | ||
// More information and diagrams about the middlewares can be found here: | ||
// https://github.com/buger/goreplay/wiki/Middleware | ||
package main | ||
|
||
import ( | ||
"bufio" | ||
"bytes" | ||
"encoding/hex" | ||
"fmt" | ||
"io" | ||
"os" | ||
"time" | ||
|
||
"github.com/buger/goreplay/proto" | ||
"github.com/stellar/go/support/log" | ||
) | ||
|
||
// maxPerSecond defines how many requests should be checked at max per second | ||
const maxPerSecond = 100 | ||
|
||
const ( | ||
requestType byte = '1' | ||
originalResponseType byte = '2' | ||
replayedResponseType byte = '3' | ||
) | ||
|
||
var lastCheck = time.Now() | ||
var reqsCheckedPerSeq = 0 | ||
var pendingRequestsAdded, ignoredCount, diffsCount, okCount int64 | ||
var pendingRequests = make(map[string]*Request) | ||
|
||
func main() { | ||
processAll(os.Stdin, os.Stderr, os.Stdout) | ||
} | ||
|
||
func processAll(stdin io.Reader, stderr, stdout io.Writer) { | ||
log.SetOut(stderr) | ||
log.SetLevel(log.InfoLevel) | ||
|
||
bufSize := 20 * 1024 * 1024 // 20MB | ||
scanner := bufio.NewScanner(stdin) | ||
buf := make([]byte, bufSize) | ||
scanner.Buffer(buf, bufSize) | ||
var maxPendingRequests = 2000 | ||
|
||
for scanner.Scan() { | ||
encoded := scanner.Bytes() | ||
buf := make([]byte, len(encoded)/2) | ||
_, err := hex.Decode(buf, encoded) | ||
if err != nil { | ||
os.Stderr.WriteString(fmt.Sprintf("hex.Decode error: %v", err)) | ||
continue | ||
} | ||
|
||
if err := scanner.Err(); err != nil { | ||
os.Stderr.WriteString(fmt.Sprintf("scanner.Err(): %v\n", err)) | ||
} | ||
|
||
process(stderr, stdout, buf) | ||
|
||
if len(pendingRequests) > maxPendingRequests { | ||
// Around 3-4% of responses is lost (not sure why) so pendingRequests can grow | ||
// indefinitely. Let's just truncate it when it becomes too big. | ||
// There is one gotcha here. Goreplay will still send requests | ||
// (`1` type payloads) even if traffic is rate limited. So if rate | ||
// limit is applied even more requests can be lost. So we should | ||
// use rate limiting implemented here when using middleware rather than | ||
// Goreplay's rate limit. | ||
pendingRequests = make(map[string]*Request) | ||
} | ||
} | ||
} | ||
|
||
func process(stderr, stdout io.Writer, buf []byte) { | ||
// First byte indicate payload type: | ||
payloadType := buf[0] | ||
headerSize := bytes.IndexByte(buf, '\n') + 1 | ||
header := buf[:headerSize-1] | ||
|
||
// Header contains space separated values of: request type, request id, and request start time (or round-trip time for responses) | ||
meta := bytes.Split(header, []byte(" ")) | ||
// For each request you should receive 3 payloads (request, response, replayed response) with same request id | ||
reqID := string(meta[1]) | ||
payload := buf[headerSize:] | ||
|
||
switch payloadType { | ||
case requestType: | ||
if time.Since(lastCheck) > time.Second { | ||
reqsCheckedPerSeq = 0 | ||
lastCheck = time.Now() | ||
|
||
// Print stats every second | ||
_, _ = os.Stderr.WriteString(fmt.Sprintf( | ||
"middleware stats: pendingRequests=%d requestsAdded=%d ok=%d diffs=%d ignored=%d\n", | ||
len(pendingRequests), | ||
pendingRequestsAdded, | ||
okCount, | ||
diffsCount, | ||
ignoredCount, | ||
)) | ||
} | ||
|
||
if reqsCheckedPerSeq < maxPerSecond { | ||
pendingRequests[reqID] = &Request{ | ||
Headers: payload, | ||
} | ||
pendingRequestsAdded++ | ||
reqsCheckedPerSeq++ | ||
} | ||
|
||
// Emitting data back, without modification | ||
_, err := io.WriteString(stdout, hex.EncodeToString(buf)+"\n") | ||
if err != nil { | ||
_, _ = io.WriteString(stderr, fmt.Sprintf("stdout.WriteString error: %v", err)) | ||
} | ||
case originalResponseType: | ||
if req, ok := pendingRequests[reqID]; ok { | ||
// Original response can arrive after mirrored so this should be improved | ||
// instead of ignoring this case. | ||
req.OriginalResponse = payload | ||
} | ||
case replayedResponseType: | ||
if req, ok := pendingRequests[reqID]; ok { | ||
req.MirroredResponse = payload | ||
|
||
if req.IsIgnored() { | ||
ignoredCount++ | ||
} else { | ||
if !req.ResponseEquals() { | ||
// TODO in the future publish the results to S3 for easier processing | ||
log.WithFields(log.F{ | ||
"expected": req.OriginalBody(), | ||
"actual": req.MirroredBody(), | ||
"headers": string(req.Headers), | ||
"path": string(proto.Path(req.Headers)), | ||
}).Info("Mismatch found") | ||
diffsCount++ | ||
} else { | ||
okCount++ | ||
} | ||
} | ||
|
||
delete(pendingRequests, reqID) | ||
} | ||
default: | ||
_, _ = io.WriteString(stderr, "Unknown message type\n") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
package main | ||
|
||
import ( | ||
"bytes" | ||
"encoding/hex" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestProcess(t *testing.T) { | ||
// For 1 type, it returns the same msg to stdout | ||
payload := "1 ID\nGET /ledgers HTTP/1.1\r\nHost: horizon.stellar.org\r\n\r\n" | ||
stdin := strings.NewReader(hex.EncodeToString([]byte(payload))) | ||
|
||
stdout := bytes.Buffer{} | ||
stderr := bytes.Buffer{} | ||
processAll(stdin, &stderr, &stdout) | ||
|
||
decodedOut, err := hex.DecodeString(strings.TrimRight(stdout.String(), "\n")) | ||
assert.NoError(t, err) | ||
assert.Equal(t, payload, string(decodedOut)) | ||
assert.Equal(t, "", stderr.String()) | ||
|
||
// For 2 type, save the original response | ||
payload = "2 ID\nHeader: true\r\n\r\nBody" | ||
stdin = strings.NewReader(hex.EncodeToString([]byte(payload))) | ||
|
||
stdout = bytes.Buffer{} | ||
stderr = bytes.Buffer{} | ||
processAll(stdin, &stderr, &stdout) | ||
|
||
assert.Len(t, pendingRequests, 1) | ||
assert.Equal(t, "", stdout.String()) | ||
assert.Equal(t, "", stderr.String()) | ||
|
||
// For 2 type, save the original response | ||
payload = "3 ID\nHeader: true\r\n\r\nBody" | ||
stdin = strings.NewReader(hex.EncodeToString([]byte(payload))) | ||
|
||
stdout = bytes.Buffer{} | ||
stderr = bytes.Buffer{} | ||
processAll(stdin, &stderr, &stdout) | ||
|
||
assert.Len(t, pendingRequests, 0) | ||
assert.Equal(t, "", stdout.String()) | ||
assert.Equal(t, "", stderr.String()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
package main | ||
|
||
import ( | ||
"bytes" | ||
"regexp" | ||
"strings" | ||
|
||
"github.com/buger/goreplay/proto" | ||
) | ||
|
||
var horizonURLs = regexp.MustCompile(`https:\/\/.*?(stellar\.org|127.0.0.1:8000)`) | ||
var findResultMetaXDR = regexp.MustCompile(`"result_meta_xdr":[ ]?"([^"]*)",`) | ||
|
||
// removeRegexps contains a list of regular expressions that, when matched, | ||
// will be changed to an empty string. This is done to exclude known | ||
// differences in responses between two Horizon version. | ||
// | ||
// Let's say that next Horizon version adds a new bool field: | ||
// `is_authorized` on account balances list. You want to remove this | ||
// field so it's not reported for each `/accounts/{id}` response. | ||
var removeRegexps = []*regexp.Regexp{} | ||
|
||
type replace struct { | ||
regexp *regexp.Regexp | ||
repl string | ||
} | ||
|
||
// replaceRegexps works like removeRegexps but replaces data | ||
var replaceRegexps = []replace{} | ||
|
||
type Request struct { | ||
Headers []byte | ||
OriginalResponse []byte | ||
MirroredResponse []byte | ||
} | ||
|
||
func (r *Request) OriginalBody() string { | ||
return string(proto.Body(r.OriginalResponse)) | ||
} | ||
|
||
func (r *Request) MirroredBody() string { | ||
return string(proto.Body(r.MirroredResponse)) | ||
} | ||
|
||
func (r *Request) IsIgnored() bool { | ||
if len(r.OriginalResponse) == 0 { | ||
return true | ||
} | ||
|
||
originalLatestLedgerHeader := proto.Header(r.OriginalResponse, []byte("Latest-Ledger")) | ||
mirroredLatestLedgerHeader := proto.Header(r.MirroredResponse, []byte("Latest-Ledger")) | ||
|
||
if !bytes.Equal(originalLatestLedgerHeader, mirroredLatestLedgerHeader) { | ||
return true | ||
} | ||
|
||
// Responses below are not supported but support can be added with some effort | ||
originalTransferEncodingHeader := proto.Header(r.OriginalResponse, []byte("Transfer-Encoding")) | ||
mirroredTransferEncodingHeader := proto.Header(r.MirroredResponse, []byte("Transfer-Encoding")) | ||
if len(originalTransferEncodingHeader) > 0 || | ||
len(mirroredTransferEncodingHeader) > 0 { | ||
return true | ||
} | ||
|
||
acceptEncodingHeader := proto.Header(r.Headers, []byte("Accept-Encoding")) | ||
if strings.Contains(string(acceptEncodingHeader), "gzip") { | ||
return true | ||
} | ||
|
||
acceptHeader := proto.Header(r.Headers, []byte("Accept")) | ||
return strings.Contains(string(acceptHeader), "event-stream") | ||
} | ||
|
||
func (r *Request) ResponseEquals() bool { | ||
originalBody := proto.Body(r.OriginalResponse) | ||
mirroredBody := proto.Body(r.MirroredResponse) | ||
|
||
return normalizeResponseBody(originalBody) == normalizeResponseBody(mirroredBody) | ||
} | ||
|
||
// normalizeResponseBody normalizes body to allow byte-byte comparison like removing | ||
// URLs from _links or tx meta. May require updating on new releases. | ||
func normalizeResponseBody(body []byte) string { | ||
normalizedBody := string(body) | ||
// `result_meta_xdr` can differ between core instances (confirmed this with core team) | ||
normalizedBody = findResultMetaXDR.ReplaceAllString(normalizedBody, "") | ||
// Remove Horizon URL from the _links | ||
normalizedBody = horizonURLs.ReplaceAllString(normalizedBody, "") | ||
|
||
for _, reg := range removeRegexps { | ||
normalizedBody = reg.ReplaceAllString(normalizedBody, "") | ||
} | ||
|
||
for _, reg := range replaceRegexps { | ||
normalizedBody = reg.regexp.ReplaceAllString(normalizedBody, reg.repl) | ||
} | ||
|
||
return normalizedBody | ||
} |
Oops, something went wrong.