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 }