github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/query/cache/service/search.go (about)

     1  // Copyright 2020 The WPT Dashboard Project. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  
    13  	"github.com/web-platform-tests/wpt.fyi/api/query"
    14  	"github.com/web-platform-tests/wpt.fyi/api/query/cache/index"
    15  	cq "github.com/web-platform-tests/wpt.fyi/api/query/cache/query"
    16  	"github.com/web-platform-tests/wpt.fyi/shared"
    17  )
    18  
    19  type searchError struct {
    20  	// Detail is the internal error that should not be exposed to the end-user.
    21  	Detail error
    22  	// Message is the user-facing error message.
    23  	Message string
    24  	// Code is the HTTP status code for this error.
    25  	Code int
    26  }
    27  
    28  func (e searchError) Error() string {
    29  	if e.Detail == nil {
    30  		return e.Message
    31  	}
    32  
    33  	return fmt.Sprintf("%s: %v", e.Message, e.Detail)
    34  }
    35  
    36  // nolint:gocognit // TODO: Fix gocognit lint error
    37  func searchHandlerImpl(w http.ResponseWriter, r *http.Request) *searchError {
    38  	ctx := r.Context()
    39  	log := shared.GetLogger(ctx)
    40  	if r.Method != http.MethodPost {
    41  		return &searchError{ // nolint:exhaustruct // TODO: Fix exhaustruct lint error.
    42  			Message: "Invalid HTTP method " + r.Method,
    43  			Code:    http.StatusBadRequest,
    44  		}
    45  	}
    46  
    47  	reqData, err := io.ReadAll(r.Body)
    48  	if err != nil {
    49  		return &searchError{
    50  			Detail:  err,
    51  			Message: "Failed to read request body",
    52  			Code:    http.StatusInternalServerError,
    53  		}
    54  	}
    55  	log.Debugf(string(reqData))
    56  	if err := r.Body.Close(); err != nil {
    57  		return &searchError{
    58  			Detail:  err,
    59  			Message: "Failed to close request body",
    60  			Code:    http.StatusInternalServerError,
    61  		}
    62  	}
    63  
    64  	var rq query.RunQuery
    65  	if err := json.Unmarshal(reqData, &rq); err != nil {
    66  		return &searchError{
    67  			Detail:  err,
    68  			Message: "Failed to unmarshal request body",
    69  			Code:    http.StatusBadRequest,
    70  		}
    71  	}
    72  
    73  	if len(rq.RunIDs) > *maxRunsPerRequest {
    74  		return &searchError{ // nolint:exhaustruct // TODO: Fix exhaustruct lint error.
    75  			Message: maxRunsPerRequestMsg,
    76  			Code:    http.StatusBadRequest,
    77  		}
    78  	}
    79  
    80  	// Ensure runs are loaded before executing query. This is best effort: It is
    81  	// possible, though unlikely, that a run may exist in the cache at this point
    82  	// and be evicted before binding the query to a query execution plan. In such
    83  	// a case, `idx.Bind()` below will return an error.
    84  	//
    85  	// Accumulate missing runs in `missing` to report which runs have initiated
    86  	// write-on-read. Return to client `http.StatusUnprocessableEntity`
    87  	// immediately if any runs are missing.
    88  	//
    89  	// `ids` and `runs` tracks run IDs and run metadata for requested runs that
    90  	// are currently resident in `idx`.
    91  	store, err := getDatastore(ctx)
    92  	if err != nil {
    93  		return &searchError{
    94  			Detail:  err,
    95  			Message: "Failed to open Datastore",
    96  			Code:    http.StatusInternalServerError,
    97  		}
    98  	}
    99  
   100  	ids := make([]int64, 0, len(rq.RunIDs))
   101  	runs := make([]shared.TestRun, 0, len(rq.RunIDs))
   102  	missing := make([]shared.TestRun, 0, len(rq.RunIDs))
   103  	for i := range rq.RunIDs {
   104  		id := index.RunID(rq.RunIDs[i])
   105  		run, err := idx.Run(id)
   106  		// If getting run metadata fails, attempt write-on-read for this run.
   107  		if err != nil {
   108  			runPtr := new(shared.TestRun)
   109  			if err := store.Get(store.NewIDKey("TestRun", int64(id)), runPtr); err != nil {
   110  				return &searchError{
   111  					Detail:  err,
   112  					Message: fmt.Sprintf("Unknown test run ID %d", id),
   113  					Code:    http.StatusBadRequest,
   114  				}
   115  			}
   116  			runPtr.ID = int64(id)
   117  
   118  			go func() {
   119  				err := idx.IngestRun(*runPtr)
   120  				if err != nil {
   121  					log.Warningf("Failed to ingest runs: %s", err.Error())
   122  				}
   123  			}()
   124  
   125  			missing = append(missing, *runPtr)
   126  		} else {
   127  			// Ensure that both `ids` and `runs` correspond to the same test runs.
   128  			ids = append(ids, rq.RunIDs[i])
   129  			runs = append(runs, run)
   130  		}
   131  	}
   132  
   133  	// Return to client `http.StatusUnprocessableEntity` immediately if any runs
   134  	// are missing.
   135  	if len(runs) == 0 && len(missing) > 0 {
   136  		data, err := json.Marshal(shared.SearchResponse{ // nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   137  			IgnoredRuns: missing,
   138  		})
   139  		if err != nil {
   140  			return &searchError{
   141  				Detail:  err,
   142  				Message: "Failed to marshal results to JSON",
   143  				Code:    http.StatusInternalServerError,
   144  			}
   145  		}
   146  		w.WriteHeader(http.StatusUnprocessableEntity)
   147  		_, err = w.Write(data)
   148  		if err != nil {
   149  			log.Warningf("Failed to write data in api/search/cache handler: %s", err.Error())
   150  		}
   151  
   152  		return nil
   153  	}
   154  
   155  	// Prepare user query based on `ids` that are (or at least were a moment ago)
   156  	// resident in `idx`. In the unlikely event that a run in `ids`/`runs` is no
   157  	// longer in `idx`, `idx.Bind()` below will return an error.
   158  	q := cq.PrepareUserQuery(ids, rq.AbstractQuery.BindToRuns(runs...))
   159  
   160  	// Configure format, from request params.
   161  	urlQuery := r.URL.Query()
   162  	subtests, _ := shared.ParseBooleanParam(urlQuery, "subtests")
   163  	interop, _ := shared.ParseBooleanParam(urlQuery, "interop")
   164  	diff, _ := shared.ParseBooleanParam(urlQuery, "diff")
   165  	diffFilter, _, err := shared.ParseDiffFilterParams(urlQuery)
   166  	if err != nil {
   167  		return &searchError{
   168  			Detail:  err,
   169  			Message: "Failed to parse diff filter",
   170  			Code:    http.StatusBadRequest,
   171  		}
   172  	}
   173  	opts := query.AggregationOpts{
   174  		IncludeSubtests:         subtests != nil && *subtests,
   175  		InteropFormat:           interop != nil && *interop,
   176  		IncludeDiff:             diff != nil && *diff,
   177  		DiffFilter:              diffFilter,
   178  		IgnoreTestHarnessResult: shared.IsFeatureEnabled(store, "ignoreHarnessInTotal"),
   179  	}
   180  	plan, err := idx.Bind(runs, q)
   181  	if err != nil {
   182  		return &searchError{
   183  			Detail:  err,
   184  			Message: "Failed to create query plan",
   185  			Code:    http.StatusInternalServerError,
   186  		}
   187  	}
   188  
   189  	results := plan.Execute(runs, opts)
   190  	res, ok := results.([]shared.SearchResult)
   191  	if !ok {
   192  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   193  		return &searchError{
   194  			Message: "Search index returned bad results",
   195  			Code:    http.StatusInternalServerError,
   196  		}
   197  	}
   198  
   199  	// Cull unchanged diffs, if applicable.
   200  	if opts.IncludeDiff && !opts.DiffFilter.Unchanged {
   201  		for i := range res {
   202  			if res[i].Diff.IsEmpty() {
   203  				res[i].Diff = nil
   204  			}
   205  		}
   206  	}
   207  
   208  	// Response always contains Runs and Results. If some runs are missing, then:
   209  	// - Add missing runs to IgnoredRuns;
   210  	// - (If no other error occurs) return `http.StatusUnprocessableEntity` to
   211  	//   client.
   212  	// nolint:exhaustruct // Not required since missing fields have omitempty.
   213  	resp := shared.SearchResponse{
   214  		Runs:    runs,
   215  		Results: res,
   216  	}
   217  	if len(missing) != 0 {
   218  		resp.IgnoredRuns = missing
   219  	}
   220  
   221  	respData, err := json.Marshal(resp)
   222  	if err != nil {
   223  		return &searchError{
   224  			Detail:  err,
   225  			Message: "Failed to marshal results to JSON",
   226  			Code:    http.StatusInternalServerError,
   227  		}
   228  	}
   229  	if len(missing) != 0 {
   230  		w.WriteHeader(http.StatusUnprocessableEntity)
   231  	}
   232  
   233  	_, err = w.Write(respData)
   234  	if err != nil {
   235  		log.Warningf("Failed to write data in api/search/cache handler: %s", err.Error())
   236  	}
   237  
   238  	return nil
   239  }