001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.data; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.time.*; 023import java.time.temporal.*; 024import java.util.*; 025import java.util.regex.*; 026import java.util.stream.*; 027 028import org.apache.juneau.http.response.*; 029import org.apache.juneau.marshall.collections.*; 030import org.apache.juneau.marshall.marshaller.*; 031import org.apache.juneau.rest.server.*; 032 033/** 034 * Answers a console data source's aggregate request in one pass over a row stream. 035 * 036 * <p> 037 * The request names a window, a bucket unit, up to three <c>groupBy</c> fields and the measures to compute. Any other 038 * query parameter is an app filter, returned untouched by {@link Request#filters()} for the app's own row query. 039 * Fields used by <c>groupBy</c> or a measure must be allowed with {@link #allowFields(String...)}. A bad request 040 * throws {@link RequestException} (HTTP 400, E-75). At most {@link #maxBuckets(int)} result cells are kept; rows that 041 * would open another cell are dropped and the result is marked truncated. 042 * 043 * <p> 044 * A group value is the text of the row's value, or null (JSON <jk>null</jk>) when the row has none, so a missing 045 * value and the text <js>"null"</js> are different groups. The null group sorts first within its bucket. 046 * 047 * <h5 class='section'>Example:</h5> 048 * <p class='bjava'> 049 * <ja>@RestGet</ja>(path=<js>"/series/aggregate"</js>) 050 * <jk>public void</jk> aggregate(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception { 051 * <jk>var</jk> <jv>q</jv> = SeriesAggregator.Request.<jsm>from</jsm>(<jv>req</jv>); 052 * <jk>try</jk> (<jk>var</jk> <jv>rows</jv> = 053 * <jv>svc</jv>.rows(<jv>q</jv>.start(), <jv>q</jv>.end(), <jv>q</jv>.filters())) { 054 * SeriesAggregator.<jsm>create</jsm>().timeField(<js>"start"</js>) 055 * .allowFields(<js>"status"</js>, <js>"ms"</js>) 056 * .maxBuckets(10_000).aggregate(<jv>q</jv>, <jv>rows</jv>).writeTo(<jv>res</jv>); 057 * } 058 * } 059 * </p> 060 * 061 * @since 10.0.0 062 */ 063@SuppressWarnings({ 064 "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape. 065 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 066}) 067public final class SeriesAggregator { 068 069 /** The bucket units, finest first. */ 070 static final List<String> UNITS = List.of("hour", "day", "week", "month"); 071 072 /** The most buckets <c>bucket=auto</c> aims for. */ 073 static final int AUTO_BUCKETS = 1000; 074 075 /** Query parameters that are part of the contract and never app filters. */ 076 static final Set<String> RESERVED = Set.of("start", "end", "bucket", "tz", "groupBy", "measure"); 077 078 private static final Pattern MEASURE = Pattern.compile("^(sum|avg|min|max)\\(([^()]+)\\)$"); 079 080 private String timeField = "time"; 081 private DayOfWeek weekStart = DayOfWeek.MONDAY; 082 private int maxBuckets = 10_000; 083 private final Set<String> allowed = new LinkedHashSet<>(); 084 085 private SeriesAggregator() {} 086 087 /** 088 * A new aggregator. Defaults: time field <c>time</c>, weeks start on Monday, at most 10,000 cells, no fields 089 * allowed. 090 * 091 * @return A new aggregator. 092 */ 093 public static SeriesAggregator create() { 094 return new SeriesAggregator(); 095 } 096 097 /** 098 * The column holding each row's time. 099 * 100 * @param value The column name. 101 * @return This object. 102 */ 103 public SeriesAggregator timeField(String value) { 104 timeField = value; 105 return this; 106 } 107 108 /** 109 * The first day of a <c>week</c> bucket. 110 * 111 * @param value The day. 112 * @return This object. 113 */ 114 public SeriesAggregator weekStart(DayOfWeek value) { 115 weekStart = value; 116 return this; 117 } 118 119 /** 120 * The most result cells (bucket and group combinations). 121 * 122 * @param value The cap. At least 1. 123 * @return This object. 124 */ 125 public SeriesAggregator maxBuckets(int value) { 126 if (value < 1) 127 throw iaex("SeriesAggregator.maxBuckets: '%s' is not a positive count", value); 128 maxBuckets = value; 129 return this; 130 } 131 132 /** 133 * The fields a request may group by or measure. With none, every such field is rejected. 134 * 135 * @param names The field names. 136 * @return This object. 137 */ 138 public SeriesAggregator allowFields(String...names) { 139 allowed.addAll(List.of(names)); 140 return this; 141 } 142 143 /** A rejected aggregate request: HTTP 400 with an E-75 message. */ 144 @SuppressWarnings({ 145 "java:S110" // The deep hierarchy comes from the REST framework's BadRequest exception chain, not from this class. 146 }) 147 public static final class RequestException extends BadRequest { 148 private static final long serialVersionUID = 1L; 149 150 /** 151 * Constructor. 152 * 153 * @param reason Why the request was rejected. 154 */ 155 public RequestException(String reason) { 156 super("E-75: aggregate request rejected: %s", reason); 157 } 158 } 159 160 /** One measure: <c>count</c>, or an op over a field. */ 161 record Measure(String text, String op, String field) { 162 static Measure parse(String s) { 163 if ("count".equals(s)) 164 return new Measure(s, "count", null); 165 var m = MEASURE.matcher(s); 166 if (! m.matches()) 167 throw new RequestException("unknown measure '" + s + "'"); 168 return new Measure(s, m.group(1), m.group(2)); 169 } 170 } 171 172 /** A parsed aggregate request. */ 173 public static final class Request { 174 private final Instant start; 175 private final Instant end; 176 private final String bucket; 177 private final List<String> groupBy; 178 private final List<Measure> measures; 179 private final Map<String,List<String>> filters; 180 181 private Request(Instant start, Instant end, String bucket, List<String> groupBy, List<Measure> measures, 182 Map<String,List<String>> filters) { 183 this.start = start; 184 this.end = end; 185 this.bucket = bucket; 186 this.groupBy = groupBy; 187 this.measures = measures; 188 this.filters = filters; 189 } 190 191 /** 192 * Parses the request's query parameters. 193 * 194 * @param req The request. 195 * @return The parsed request. 196 * @throws RequestException If the request breaks the contract. 197 */ 198 public static Request from(RestRequest req) { 199 var m = new LinkedHashMap<String,List<String>>(); 200 req.getQueryParams().forEach(p -> m.computeIfAbsent(p.getName(), k -> new ArrayList<>()).add(p.getValue())); 201 return of(m); 202 } 203 204 /** 205 * Parses query parameters given as a map. 206 * 207 * @param query Each parameter name and its values, in order. 208 * @return The parsed request. 209 * @throws RequestException If the request breaks the contract. 210 */ 211 public static Request of(Map<String,? extends List<String>> query) { 212 var start = instant(query, "start"); 213 var end = instant(query, "end"); 214 if (! end.isAfter(start)) 215 throw new RequestException("'end' must be after 'start'"); 216 var bucket = one(query, "bucket", "auto"); 217 if (! "auto".equals(bucket) && ! UNITS.contains(bucket)) 218 throw new RequestException("unknown bucket '" + bucket + "'"); 219 var tz = one(query, "tz", "UTC"); 220 if (! "UTC".equals(tz)) 221 throw new RequestException("only tz 'UTC' is supported, not '" + tz + "'"); 222 var groupBy = split(query.get("groupBy")); 223 if (groupBy.size() > 3) 224 throw new RequestException("at most 3 groupBy fields, not " + groupBy.size()); 225 var ms = split(query.get("measure")); 226 var measures = (ms.isEmpty() ? List.of("count") : ms).stream().map(Measure::parse).toList(); 227 var seen = new HashSet<String>(); 228 for (var g : groupBy) { 229 if ("bucket".equals(g)) 230 throw new RequestException("groupBy field 'bucket' is reserved"); 231 if (! seen.add(g)) 232 throw new RequestException("duplicate groupBy field '" + g + "'"); 233 } 234 var texts = new HashSet<String>(); 235 for (var m : measures) { 236 if (! texts.add(m.text())) 237 throw new RequestException("duplicate measure '" + m.text() + "'"); 238 if (seen.contains(m.text())) 239 throw new RequestException("groupBy field '" + m.text() + "' collides with a measure"); 240 } 241 var filters = new TreeMap<String,List<String>>(); 242 query.forEach((k, v) -> { 243 if (! RESERVED.contains(k) && v != null) 244 filters.put(k, v.stream().map(x -> x == null ? "" : x).toList()); 245 }); 246 return new Request(start, end, bucket, groupBy, measures, Collections.unmodifiableMap(filters)); 247 } 248 249 /** @return The window start (inclusive). */ 250 public Instant start() { return start; } 251 252 /** @return The window end (exclusive). */ 253 public Instant end() { return end; } 254 255 /** @return The bucket unit, or <c>auto</c>. */ 256 public String bucket() { return bucket; } 257 258 /** @return The groupBy fields, in order. */ 259 public List<String> groupBy() { return groupBy; } 260 261 /** @return The app filters: every non-contract parameter and its values, untouched. */ 262 public Map<String,List<String>> filters() { return filters; } 263 264 private static String one(Map<String,? extends List<String>> q, String name, String def) { 265 var v = q.get(name); 266 return v == null || v.isEmpty() || v.get(0) == null || v.get(0).isEmpty() ? def : v.get(0); 267 } 268 269 private static Instant instant(Map<String,? extends List<String>> q, String name) { 270 var s = one(q, name, null); 271 if (s == null) 272 throw new RequestException("'" + name + "' is required"); 273 var i = Values.parseInstant(s); 274 if (i == null) 275 throw new RequestException( 276 "'" + name + "' is not an ISO-8601 instant or epoch milliseconds: '" + s + "'"); 277 return i; 278 } 279 280 // groupBy is comma-separated or repeated; measure takes the same forms (sum(a,b) is not a valid measure 281 // anyway). 282 private static List<String> split(List<String> values) { 283 if (values == null) return List.of(); 284 return values.stream().filter(Objects::nonNull).flatMap(v -> Arrays.stream(v.split(","))) 285 .map(String::trim).filter(s -> ! s.isEmpty()).toList(); 286 } 287 } 288 289 /** An aggregate result in columnar form: the dims, their types, and one value list per dim. */ 290 public static final class Result { 291 private final List<String> dims; 292 private final List<String> types; 293 private final Map<String,List<Object>> columns; 294 private final boolean truncated; 295 296 Result(List<String> dims, List<String> types, Map<String,List<Object>> columns, boolean truncated) { 297 this.dims = dims; 298 this.types = types; 299 this.columns = columns; 300 this.truncated = truncated; 301 } 302 303 /** @return The dim names: <c>bucket</c>, the groupBy fields, then the measures. */ 304 public List<String> dims() { return dims; } 305 306 /** 307 * @return Each dim's values, in row order; <c>bucket</c> is epoch milliseconds UTC and a group value may be 308 * null. 309 */ 310 public Map<String,List<Object>> columns() { return columns; } 311 312 /** @return True when the cell cap dropped rows. */ 313 public boolean truncated() { return truncated; } 314 315 /** 316 * The response body: <c>{schemaVersion, dims:[{name,type}], columns, truncated}</c>. 317 * 318 * @return A new map. 319 */ 320 public JsonMap toMap() { 321 var d = new ArrayList<Object>(); 322 for (var i = 0; i < dims.size(); i++) 323 d.add(JsonMap.of("name", dims.get(i), "type", types.get(i))); 324 var c = new JsonMap(); 325 c.putAll(columns); 326 return JsonMap.of("schemaVersion", 1, "dims", d, "columns", c, "truncated", truncated); 327 } 328 329 /** 330 * Writes the body as <c>application/json</c> with <c>nosniff</c>. 331 * 332 * @param res The response. 333 * @throws IOException If the writer fails. 334 */ 335 @SuppressWarnings({ 336 "resource" // The response writer is owned and closed by the container. 337 }) 338 public void writeTo(RestResponse res) throws IOException { 339 res.setContentType("application/json;charset=utf-8"); 340 res.setHeader("X-Content-Type-Options", "nosniff"); 341 res.getWriter().write(Json.of(toMap())); 342 } 343 } 344 345 /** 346 * Aggregates the rows in one pass. Rows with a null or out-of-window time are skipped. 347 * 348 * @param req The request. 349 * @param rows The rows, already filtered by the app. 350 * @return The result. 351 * @throws RequestException If the request names a field that is not allowed. 352 */ 353 @SuppressWarnings({ 354 "java:S135" // Guard clauses keep the row loop readable 355 }) 356 public Result aggregate(Request req, Stream<? extends SeriesSource.Row> rows) { 357 var fields = new ArrayList<String>(req.groupBy); 358 req.measures.stream().map(Measure::field).filter(Objects::nonNull).forEach(fields::add); 359 for (var f : fields) 360 if (! allowed.contains(f)) 361 throw new RequestException("field '" + f + "' is not allowed"); 362 var unit = "auto".equals(req.bucket) ? autoUnit(req.start, req.end) : req.bucket; 363 var cells = new TreeMap<List<Object>,double[][]>(SeriesAggregator::compareKeys); 364 var truncated = false; 365 var it = rows.iterator(); 366 while (it.hasNext()) { 367 var r = it.next(); 368 var t = Values.toInstant(r.get(timeField)); 369 if (t == null || t.isBefore(req.start) || ! t.isBefore(req.end)) 370 continue; 371 var key = new ArrayList<Object>(req.groupBy.size() + 1); 372 key.add(floor(t, unit).toEpochMilli()); 373 for (var g : req.groupBy) 374 key.add(r.get(g) == null ? null : String.valueOf(r.get(g))); 375 var acc = cells.get(key); 376 if (acc == null) { 377 if (cells.size() == maxBuckets) { 378 truncated = true; 379 continue; 380 } 381 acc = newAccumulators(req.measures.size()); 382 cells.put(key, acc); 383 } 384 accumulate(req.measures, acc, r); 385 } 386 var dims = new ArrayList<String>(List.of("bucket")); 387 var types = new ArrayList<String>(List.of("time")); 388 dims.addAll(req.groupBy); 389 req.groupBy.forEach(g -> types.add("string")); 390 req.measures.forEach(m -> { dims.add(m.text()); types.add("number"); }); 391 var columns = new LinkedHashMap<String,List<Object>>(); 392 dims.forEach(d -> columns.put(d, new ArrayList<>(cells.size()))); 393 cells.forEach((k, acc) -> { 394 for (var i = 0; i < k.size(); i++) 395 columns.get(dims.get(i)).add(k.get(i)); 396 for (var i = 0; i < req.measures.size(); i++) 397 columns.get(req.measures.get(i).text()).add(result(req.measures.get(i).op(), acc[i])); 398 }); 399 return new Result(List.copyOf(dims), List.copyOf(types), columns, truncated); 400 } 401 402 // Each acc[i] holds four slots in order: count, sum, min, max. 403 private static double[][] newAccumulators(int n) { 404 var a = new double[n][]; 405 for (var i = 0; i < n; i++) 406 a[i] = new double[] { 0, 0, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY }; 407 return a; 408 } 409 410 @SuppressWarnings({ 411 "java:S135" // Guard clauses keep the accumulation readable 412 }) 413 private static void accumulate(List<Measure> measures, double[][] acc, SeriesSource.Row r) { 414 for (var i = 0; i < measures.size(); i++) { 415 var m = measures.get(i); 416 if (m.field() == null) { 417 acc[i][0]++; 418 continue; 419 } 420 if (! (r.get(m.field()) instanceof Number n) || Values.nonFinite(n)) 421 continue; 422 var d = n.doubleValue(); 423 acc[i][0]++; 424 acc[i][1] += d; 425 acc[i][2] = Math.min(acc[i][2], d); 426 acc[i][3] = Math.max(acc[i][3], d); 427 } 428 } 429 430 // Overflow gives Infinity, which is not valid JSON; write null instead. 431 private static Double finite(double d) { 432 return Double.isFinite(d) ? d : null; 433 } 434 435 private static Object result(String op, double[] a) { 436 return switch (op) { 437 case "count" -> (long)a[0]; 438 case "sum" -> finite(a[1]); 439 case "avg" -> a[0] == 0 ? null : finite(a[1] / a[0]); 440 case "min" -> a[0] == 0 ? null : a[2]; 441 default -> a[0] == 0 ? null : a[3]; 442 }; 443 } 444 445 // The bucket first, then each group value; a null group (a row with no value) sorts before any text. 446 @SuppressWarnings({ 447 "unchecked" // Non-null group values within a series are mutually Comparable. 448 }) 449 private static int compareKeys(List<Object> a, List<Object> b) { 450 for (var i = 0; i < a.size(); i++) { 451 var x = a.get(i); 452 var y = b.get(i); 453 if (x == null || y == null) { 454 if (x == y) continue; 455 return x == null ? -1 : 1; 456 } 457 var c = ((Comparable<Object>)x).compareTo(y); 458 if (c != 0) return c; 459 } 460 return 0; 461 } 462 463 // The smallest unit giving at most AUTO_BUCKETS buckets over the window; month when none does. Counting stops at 464 // AUTO_BUCKETS + 1, so a long window never walks every hour. 465 String autoUnit(Instant start, Instant end) { 466 for (var u : UNITS) { 467 var n = 0; 468 for (var t = floor(start, u); t.isBefore(end) && n <= AUTO_BUCKETS; t = next(t, u)) 469 n++; 470 if (n <= AUTO_BUCKETS) return u; 471 } 472 return "month"; 473 } 474 475 Instant floor(Instant t, String unit) { 476 var d = t.atZone(ZoneOffset.UTC); 477 return switch (unit) { 478 case "hour" -> d.truncatedTo(ChronoUnit.HOURS).toInstant(); 479 case "day" -> d.truncatedTo(ChronoUnit.DAYS).toInstant(); 480 case "week" -> d.truncatedTo(ChronoUnit.DAYS).with(TemporalAdjusters.previousOrSame(weekStart)).toInstant(); 481 default -> d.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).toInstant(); 482 }; 483 } 484 485 static Instant next(Instant t, String unit) { 486 var d = t.atZone(ZoneOffset.UTC); 487 return switch (unit) { 488 case "hour" -> d.plusHours(1).toInstant(); 489 case "day" -> d.plusDays(1).toInstant(); 490 case "week" -> d.plusWeeks(1).toInstant(); 491 default -> d.plusMonths(1).toInstant(); 492 }; 493 } 494}