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.petstore.console.ops; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.time.*; 023import java.util.*; 024import java.util.concurrent.*; 025import java.util.concurrent.atomic.*; 026import java.util.function.*; 027 028import org.apache.juneau.commons.inject.*; 029import org.apache.juneau.http.*; 030import org.apache.juneau.http.response.*; 031import org.apache.juneau.marshall.collections.*; 032import org.apache.juneau.petstore.console.*; 033import org.apache.juneau.petstore.console.data.*; 034import org.apache.juneau.petstore.console.pages.*; 035import org.apache.juneau.petstore.dto.*; 036import org.apache.juneau.rest.server.*; 037import org.apache.juneau.rest.server.view.*; 038import org.apache.juneau.rest.server.view.freemarker.*; 039import org.apache.juneau.console.views.*; 040import org.apache.juneau.terminal.region.*; 041import org.apache.juneau.console.widgets.*; 042 043/** 044 * Jobs: the console's async-job reference. A restock submits once per idempotency key, answers 045 * 202 with an {@link AsyncJobRef}, and the views runtime follows its {@code streamUrl} until the job settles. 046 * 047 * <p> 048 * The registry de-duplicates on the idempotency key itself ({@link AsyncJobRegistry#tryCreate(IdempotencyKey)}), so a 049 * resubmit with the same key returns the same job and this resource keeps no key map of its own. 050 * 051 * <p> 052 * The page's {@code job-log} card posts the same restock in its flat form and follows the job's stream. A restock 053 * with {@code slowMs} waits that long before it touches the store (cancel it then and nothing changes), and every 054 * restock holds for {@code holdMs} (a second by default) after it has added its pets (cancel it then and the result 055 * says the change was made). 056 * The registry's hard timeout is 30 s, or whatever a {@link HardTimeout} bean on the host says; a {@link GroomPeriod} 057 * bean sets the groom step and a {@link SpeciesHold} bean the species-row hold. 058 * 059 * <p> 060 * The seeded groom run is created on the first groom request rather than at construction, so a {@link GroomPeriod} bean 061 * (injected after construction) can set its pace; every groom endpoint goes through it, so the page sees the same data. 062 * 063 * <p> 064 * The page also hosts the terminal demo: a "groom pet" job ({@link GroomRuns}) whose output is served by 065 * {@link TerminalMixin} and shown two ways — a FreeMarker terminal card over the in-memory output, and a second 066 * card over the same run's file-backed transcript. 067 * 068 * <h5 class='section'>Example:</h5> 069 * <p class='bjava'> 070 * <jc>// POST /console/ops/jobs/restock {DOG:2, slowMs:3000, holdMs:1000, idempotencyKey:'7f3c...'}</jc> 071 * <jk>var</jk> <jv>body</jv> = Restock.<jsm>of</jsm>(<jv>json</jv>); 072 * <jk>var</jk> <jv>job</jv> = asyncJobRegistry().tryCreate(IdempotencyKey.<jsm>of</jsm>(<jv>body</jv>.idempotencyKey(), <js>"restock"</js>, <js>"restock"</js>)).orElse(<jk>null</jk>); 073 * <jk>if</jk> (<jv>job</jv> == <jk>null</jk>) 074 * <jk>return</jk> ActionResult.<jsm>refusal</jsm>(<js>"app:job-limit"</js>); <jc>// answered 429</jc> 075 * <jv>pool</jv>.execute(() -> run(<jv>job</jv>, <jv>body</jv>, () -> store().restock(<jv>body</jv>.perSpecies(), <js>"job:restock"</js>))); 076 * <jk>return</jk> AsyncJobRef.<jsm>of</jsm>(<jv>job</jv>); <jc>// answered 202</jc> 077 * </p> 078 */ 079@Rest(path="/jobs", title="Jobs") 080@SuppressWarnings({ 081 "java:S110", // Inheritance depth comes from the BasicRestServlet hierarchy, not this demo page. 082 "java:S1192", // Duplicated literals are wire keys of the job rows; constants would obscure the schema. 083 "java:S2654", // The demo intentionally runs restocks on its own pool and serializes the idempotency check. 084 "resource" // The registry and pool are owned by this resource and closed in closeJobs(); Eclipse JDT @Owning warning is by design. 085}) 086public class JobsRest extends PetstoreConsolePage implements AsyncJobsMixin, TerminalMixin { 087 088 private static final long serialVersionUID = 1L; 089 090 /** System property: the most restocks that may run at once (default 2; the registry's own cap is 8). */ 091 public static final String LIMIT_PROPERTY = "petstore.jobs.limit"; 092 093 /** 094 * System property: how long a species row's restock holds after its effect, in milliseconds (default {@value #DEFAULT_HOLD_MS}, at most {@value #MAX_HOLD_MS}). 095 * 096 * <p> 097 * Read on every request, so a test can widen the window in which a cancel lands as {@code cancelled-after-effect}. 098 * A sample-only knob: the row form never accepts a hold. 099 */ 100 public static final String SPECIES_HOLD_PROPERTY = "petstore.jobs.speciesHoldMs"; 101 102 private static final String ACTION = "restock"; 103 104 /** Where this resource is mounted; the terminal URLs are built from it. */ 105 public static final String MOUNT = "/console/ops/jobs"; 106 107 private static final String GROOM_JOB = "groom"; 108 109 /** The species row's restock action; its keys come from the ledger, bound to the species and the caller. */ 110 static final String RESTOCK_SPECIES = "restock-species"; 111 112 /** The groom run row's "groom again" action. */ 113 static final String REGROOM = "regroom"; 114 115 /** Row keys: issued by the {@code /key} endpoints, checked and claimed by the row submits. */ 116 private final transient JobKeyLedger jobKeys = JobKeyLedger.create(); 117 118 /** When each species was last restocked from its row, for the row's detail panel. */ 119 private final transient Map<Species,Instant> lastRestock = new ConcurrentHashMap<>(); 120 121 /** The groom-pet demo runs, created on first use; one verbose run is seeded so the page always has a log. */ 122 private final transient AtomicReference<GroomRuns> groomRef = new AtomicReference<>(); 123 124 /** The default hard timeout of a restock job. */ 125 public static final Duration DEFAULT_HARD_TIMEOUT = Duration.ofSeconds(30); 126 127 /** The longest {@code slowMs} a restock accepts. */ 128 public static final int MAX_SLOW_MS = 10_000; 129 130 /** The longest {@code holdMs} a restock accepts. */ 131 public static final int MAX_HOLD_MS = 10_000; 132 133 /** How long a restock holds after its effect by default, so a late cancel lands as {@code cancelled-after-effect}. */ 134 public static final int DEFAULT_HOLD_MS = 1000; 135 136 private static final long SLOW_STEP_MS = 100; 137 138 /** 139 * A per-host override of the jobs hard timeout. Declare it as a {@code @Bean} on the host that mounts the 140 * console; it is per instance, so one test host's short timeout does not leak into another. 141 * 142 * <h5 class='section'>Example:</h5> 143 * <p class='bjava'> 144 * <ja>@Bean</ja> <jk>public</jk> JobsRest.HardTimeout jobsHardTimeout() { 145 * <jk>return new</jk> JobsRest.HardTimeout(Duration.<jsm>ofSeconds</jsm>(5)); 146 * } 147 * </p> 148 * 149 * @param value The hard timeout of every restock job. Must be positive. 150 */ 151 public record HardTimeout(Duration value) {} 152 153 /** 154 * A per-host clock for the row-key ledger, so a test can age a key past its TTL. Declare it as a {@code @Bean} on 155 * the host, as {@link HardTimeout}. 156 * 157 * @param value The clock. 158 */ 159 public record KeyClock(Clock value) {} 160 161 @SuppressWarnings({ 162 "java:S2226" // the field is set by the framework's @Bean injection after construction, so it cannot be final. 163 }) 164 @Bean 165 private transient HardTimeout hardTimeout; 166 167 /** 168 * A per-host groom step period. Declare it as a {@code @Bean} on the host, as {@link HardTimeout}. 169 * 170 * @param value Time between steps, at least 1 ms. 171 */ 172 public record GroomPeriod(Duration value) {} 173 174 /** 175 * A per-host species-row hold: how long a species row's restock holds after its effect. Declare it as a 176 * {@code @Bean} on the host, as {@link HardTimeout}. 177 * 178 * @param value The post-effect hold, 0 to {@value #MAX_HOLD_MS} ms. 179 */ 180 public record SpeciesHold(Duration value) {} 181 182 @SuppressWarnings({ 183 "java:S2226" // the field is set by the framework's @Bean injection after construction, so it cannot be final. 184 }) 185 @Bean 186 private transient KeyClock keyClock; 187 188 @SuppressWarnings({ 189 "java:S2226" // the field is set by the framework's @Bean injection after construction, so it cannot be final. 190 }) 191 @Bean 192 private transient GroomPeriod groomPeriod; 193 194 @SuppressWarnings({ 195 "java:S2226" // the field is set by the framework's @Bean injection after construction, so it cannot be final. 196 }) 197 @Bean 198 private transient SpeciesHold speciesHold; 199 200 private final transient AtomicReference<AsyncJobRegistry> jobs = new AtomicReference<>(); 201 private final transient ExecutorService pool = Executors.newCachedThreadPool(r -> { 202 var t = new Thread(r, "petstore-restock"); 203 t.setDaemon(true); 204 return t; 205 }); 206 /** Submitted jobs, newest first, for the Jobs table. */ 207 private final transient Deque<JsonMap> submitted = new ConcurrentLinkedDeque<>(); 208 209 /** How many submitted jobs the table remembers. */ 210 static final int MAX_SUBMITTED = 100; 211 212 /** 213 * The restock request. 214 * 215 * <h5 class='section'>Example:</h5> 216 * <p class='bjava'> 217 * <jc>// Wire form: {perSpecies:{DOG:2, CAT:1}, idempotencyKey:'7f3c...'}</jc> 218 * <jk>var</jk> <jv>r</jv> = <jk>new</jk> Restock(Map.<jsm>of</jsm>(Species.<jsf>DOG</jsf>, 2), <js>"7f3c"</js>, 0, 1000); 219 * </p> 220 * 221 * @param perSpecies How many pets of each species to add (1-50 each). 222 * @param idempotencyKey The client-minted key; resubmits with the same key return the same job. 223 * @param slowMs How long to wait before restocking, in whole milliseconds (0-{@value JobsRest#MAX_SLOW_MS}), so a demo can 224 * cancel before the effect. 225 * @param holdMs How long to hold after restocking, in whole milliseconds (0-{@value JobsRest#MAX_HOLD_MS}, default 226 * {@value JobsRest#DEFAULT_HOLD_MS}), so a demo can cancel after the effect. 227 */ 228 public record Restock(Map<Species,Integer> perSpecies, String idempotencyKey, int slowMs, int holdMs) { 229 230 /** 231 * Reads a restock from any of its wire shapes: the API form {@code {perSpecies:{DOG:2}, idempotencyKey}}, the 232 * dialog form the views runtime posts, {@code {action, targetId, idempotencyKey, fields:{DOG:"2"}}}, or the 233 * job-log card's flat form {@code {DOG:2, slowMs:0, idempotencyKey}}. In the dialog and flat forms a blank, 234 * <jk>null</jk> or {@code 0} count means "none of that species". Any form may carry {@code slowMs} and 235 * {@code holdMs}; each must be a whole number of milliseconds, so a decimal such as {@code 1.5} is rejected 236 * rather than truncated. 237 * 238 * @param body The request body. 239 * @return The restock. 240 * @throws BadRequest If a species is unknown, a count is not a whole number, {@code slowMs} is not a whole 241 * number 0-{@value JobsRest#MAX_SLOW_MS}, or {@code holdMs} is not a whole number 0-{@value JobsRest#MAX_HOLD_MS}. 242 */ 243 public static Restock of(JsonMap body) { 244 var per = new LinkedHashMap<Species,Integer>(); 245 try { 246 var api = body.getMap("perSpecies"); 247 var form = body.getMap("fields"); 248 if (api != null) { 249 api.forEach((k, v) -> per.put(Species.valueOf(k), v == null ? null : Integer.valueOf(String.valueOf(v)))); 250 } else if (form != null) { 251 form.forEach((k, v) -> putCount(per, Species.valueOf(k), v)); 252 } else { 253 for (var s : Species.values()) 254 putCount(per, s, body.get(s.name())); 255 } 256 } catch (RuntimeException e) { // NumberFormat/IllegalArgument for bad counts or species; a conversion error when perSpecies/fields is not a map. 257 throw new BadRequest("Restock counts must be whole numbers for known species"); 258 } 259 return new Restock(per, body.getString("idempotencyKey"), millis(body, "slowMs", 0, MAX_SLOW_MS), millis(body, "holdMs", DEFAULT_HOLD_MS, MAX_HOLD_MS)); 260 } 261 262 private static void putCount(Map<Species,Integer> per, Species s, Object v) { 263 var x = v == null ? "" : String.valueOf(v).trim(); 264 if (! x.isEmpty() && ! "0".equals(x)) 265 per.put(s, Integer.valueOf(x)); 266 } 267 268 private static int millis(JsonMap body, String name, int dflt, int max) { 269 var v = body.get(name); 270 if (v == null || String.valueOf(v).isBlank()) 271 return dflt; 272 try { 273 var n = Integer.parseInt(String.valueOf(v).trim()); 274 if (n >= 0 && n <= max) 275 return n; 276 } catch (NumberFormatException e) { 277 // Not a whole number (a decimal included): falls through to the 400 below. 278 } 279 throw new BadRequest("Invalid %s '%s'; must be a whole number 0-%s", name, v, max); 280 } 281 } 282 283 /** @return The groom runs, created (with their seeded run) on first use. */ 284 private GroomRuns groomRuns() { 285 var g = groomRef.get(); 286 if (g == null) { 287 var n = GroomRuns.seeded(groomPeriod != null ? groomPeriod.value().toMillis() : Long.getLong(GroomRuns.PERIOD_PROPERTY, GroomRuns.DEFAULT_PERIOD_MS)); 288 if (groomRef.compareAndSet(null, n)) { 289 g = n; 290 } else { 291 n.close(); 292 g = groomRef.get(); 293 } 294 } 295 return g; 296 } 297 298 /** @return The row-key ledger, on the host's {@link KeyClock} if it declares one. */ 299 private JobKeyLedger jobKeys() { 300 var c = keyClock; 301 return c == null ? jobKeys : jobKeys.clock(c.value()); 302 } 303 304 @Override /* AsyncJobsMixin */ 305 public AsyncJobRegistry asyncJobRegistry() { 306 var j = jobs.get(); 307 if (j == null) { 308 synchronized (this) { 309 j = jobs.get(); 310 if (j == null) { 311 j = new AsyncJobRegistry(hardTimeout == null ? DEFAULT_HARD_TIMEOUT : hardTimeout.value()); 312 jobs.set(j); 313 } 314 } 315 } 316 return j; 317 } 318 319 /** 320 * Stops the restock pool, the registry's timeout scheduler and the groom runs when the resource is destroyed. 321 */ 322 @RestDestroy 323 public void closeJobs() { 324 pool.shutdownNow(); 325 var j = jobs.get(); 326 if (j != null) 327 j.close(); 328 var g = groomRef.get(); 329 if (g != null) 330 g.close(); 331 } 332 333 /** 334 * Renders the Jobs page. 335 * 336 * @param req The request; {@code ?builder} renders the builder twin. 337 * @param run The groom run to show, or <jk>null</jk> for the newest. 338 * @return The page view. 339 * @throws NotFound If there is no groom run to show. 340 */ 341 @RestGet(path="/") 342 public View page(RestRequest req, @Query("run") String run) { 343 var r = groomRuns().get(run).orElseGet(groomRuns()::latest); 344 if (r == null) 345 throw new NotFound("No groom run to show"); 346 var main = terminal("groom-output", r.id(), "Groom log"); 347 var file = terminal("groom-file", r.id() + GroomRuns.FILE_SUFFIX, "Transcript file"); 348 if (builder(req)) 349 return twin(req, OpsPageBuilders.jobs(main, file)); 350 return FreemarkerView.of("jobs.ftlh") 351 .attr("groomBytesUrl", main.bytesUrl) 352 .attr("groomFileBytesUrl", file.bytesUrl); 353 } 354 355 private static TerminalDef terminal(String id, String terminalId, String title) { 356 var def = TerminalDef.forMixin(id, MOUNT, terminalId).title(title); 357 def.inputId = null; // Output only: the groom terminals take no keystrokes. 358 return def.validate(); 359 } 360 361 /** 362 * The restock dialog's form source: a read-only GET returning the modal with a freshly minted self-targeted key. 363 * 364 * <p> 365 * The dialog is the plain restock, so it asks only for counts and always runs with no wait and the default 366 * {@value #DEFAULT_HOLD_MS} ms hold. The timing knobs ({@code slowMs}, {@code holdMs}) belong to the job-log card, 367 * which exists to demonstrate cancelling before and after the effect. 368 * 369 * @return The modal. 370 */ 371 @RestGet(path="/restock-form") 372 public ModalDef restockForm() { 373 var form = FormDef.create(); 374 for (var s : Species.values()) 375 form.field(FormDef.Input.of(s.name(), s.name().charAt(0) + s.name().substring(1).toLowerCase() + " (0-50)", "text").value("0")); 376 return ModalDef.create("Restock the store").form(form) 377 .idempotencyKey(IdempotencyKey.mintSelfTargeted(ACTION).value()).selfTargeted(true); 378 } 379 380 /** 381 * @return Submitted jobs, newest first, with their current state. 382 */ 383 @RestGet(path="/rows") 384 public List<JsonMap> rows() { 385 return submitted.stream().map(x -> { 386 var row = new JsonMap(state(x.getString("jobId"))).append(x); 387 row.remove("idempotencyKey"); 388 return row; 389 }).toList(); 390 } 391 392 /** 393 * Submits a restock. 394 * 395 * @param req The request. 396 * @param res The response (202). 397 * @param json The restock body (see {@link Restock#of(JsonMap)}). 398 * @return The job ref (202), or a 429 {@code app:job-limit} refusal whose message names the running count, so a 399 * JSON client such as the job-log card can show it. 400 * @throws BadRequest On a missing key, an out-of-range count, or a bad {@code slowMs} or {@code holdMs} (see 401 * {@link Restock#of(JsonMap)}). 402 */ 403 @RestPost(path="/restock") 404 public Object restock(RestRequest req, RestResponse res, @Content JsonMap json) { 405 var actor = ConsoleWrites.actor(req); 406 var body = Restock.of(json); 407 var k = body.idempotencyKey(); 408 if (ib(k)) 409 throw new BadRequest("Missing 'idempotencyKey'"); 410 var key = IdempotencyKey.of(k, ACTION, k.equals(json.getString("targetId")) ? k : ACTION); 411 if (body.perSpecies().isEmpty()) 412 throw new BadRequest("Nothing to restock"); 413 body.perSpecies().forEach((s, n) -> { 414 if (n == null || n < 1 || n > 50) 415 throw new BadRequest("Count for '%s' must be between 1 and 50", s); 416 }); 417 var limit = Integer.getInteger(LIMIT_PROPERTY, 2); 418 var registry = asyncJobRegistry(); 419 AsyncJob job; 420 synchronized (submitted) { 421 // Resolve the idempotency key first: a replay returns its existing job and never counts against the limit. 422 var prior = submitted.stream().filter(x -> k.equals(x.getString("idempotencyKey"))).findFirst(); 423 job = prior.flatMap(x -> registry.get(x.getString("jobId"))).orElse(null); 424 if (job == null) { 425 prior.ifPresent(submitted::remove); // The job aged out of the registry; this is a fresh submission. 426 var created = registry.runningCount() >= limit ? null : registry.tryCreate(key).orElse(null); 427 if (created == null) 428 return limitReached(res, registry.runningCount()); 429 job = created; 430 submitted.addFirst(JsonMap.of("jobId", created.id(), "submitted", created.createdAt().toString(), "request", body.perSpecies(), "idempotencyKey", k)); 431 while (submitted.size() > MAX_SUBMITTED) 432 submitted.removeLast(); 433 pool.execute(() -> run(created, body, () -> store().restock(body.perSpecies(), "job:restock:" + actor))); 434 } 435 } 436 res.setStatus(202); 437 return AsyncJobRef.of(job); 438 } 439 440 private static ActionResult limitReached(RestResponse res, int running) { 441 res.setStatus(429); 442 return ActionResult.refusal("app:job-limit").message(f("Job limit reached; '%s' running", running)); 443 } 444 445 /** 446 * Runs one restock: the optional slow wait (cancellable, nothing changed yet), the restock itself, then the 447 * after-effect hold. Every step stops as soon as the job has settled (a cancel or the hard timeout). 448 * 449 * @param job The job. 450 * @param body The restock. 451 * @param effect The restock itself; returns the added pets. 452 */ 453 static void run(AsyncJob job, Restock body, Supplier<List<Pet>> effect) { 454 run(job, body, effect, () -> null); 455 } 456 457 /** 458 * Same as {@link #run(AsyncJob, Restock, Supplier)}, but the success result carries a value, read after the 459 * effect: a row job returns its row's new state, which the card's {@code output} writes back into the row. 460 * 461 * @param job The job. 462 * @param body The restock. 463 * @param effect The restock itself; returns the added pets. 464 * @param value The success result's value. 465 */ 466 static void run(AsyncJob job, Restock body, Supplier<List<Pet>> effect, Supplier<Object> value) { 467 try { 468 if (body.slowMs() > 0) { 469 job.progress(f("Waiting %s ms before restocking", body.slowMs())); 470 if (! hold(job, body.slowMs())) 471 return; 472 } 473 // One lock acquisition, shared with cancel and the timeout: a job settled after the wait is left as it was. 474 if (! job.markEffectStarted()) 475 return; 476 var total = body.perSpecies().values().stream().mapToInt(Integer::intValue).sum(); 477 job.progress(f("Restocking %s pets", total)); 478 var pets = effect.get(); 479 pets.forEach(p -> job.progress(f("Added %s", p.getName()))); 480 if (hold(job, body.holdMs())) 481 job.complete(ActionResult.success(value.get()).message(f("Restocked %s pets", pets.size()))); 482 } catch (InterruptedException e) { 483 Thread.currentThread().interrupt(); 484 job.complete(ActionResult.unknown().message("Interrupted")); 485 } catch (RuntimeException e) { 486 job.complete(ActionResult.failure().message(e.getMessage())); 487 } 488 } 489 490 /** Sleeps in short steps; returns <jk>false</jk> as soon as the job has settled. */ 491 private static boolean hold(AsyncJob job, long ms) throws InterruptedException { 492 for (var left = ms; left > 0; left -= SLOW_STEP_MS) { 493 if (job.isTerminal()) 494 return false; 495 Thread.sleep(Math.min(SLOW_STEP_MS, left)); 496 } 497 return ! job.isTerminal(); 498 } 499 500 /** 501 * @param id The job id. 502 * @return {@code {state: RUNNING|DONE, result}}. 503 * @throws NotFound If the job is unknown or has aged out. 504 */ 505 @RestGet(path="/{id}") 506 public JsonMap job(@Path("id") String id) { 507 if (asyncJobRegistry().get(id).isEmpty()) 508 throw new NotFound("Unknown job '%s'", id); 509 return state(id); 510 } 511 512 private JsonMap state(String id) { 513 var j = asyncJobRegistry().get(id); 514 if (j.isEmpty()) 515 return JsonMap.of("state", "EXPIRED"); 516 return j.get().isTerminal() 517 ? JsonMap.of("state", "DONE", "result", j.get().result()) 518 : JsonMap.of("state", "RUNNING"); 519 } 520 521 @Override /* TerminalMixin */ 522 public Optional<TerminalSource> terminalSource(String id, RestRequest req) { 523 return groomRuns().source(id); // The run id is an unguessable 128-bit secret; knowing it is the access rule. 524 } 525 526 /** 527 * The Groom dialog's form source. 528 * 529 * @return The modal. 530 */ 531 @RestGet(path="/groom-form") 532 public ModalDef groomForm() { 533 var form = FormDef.create(); 534 form.field(FormDef.Input.of("pet", "Pet name", "text").value("Rex")); 535 form.field(FormDef.Input.of("verbose", "Verbose (8000 extra lines)", "checkbox")); 536 return ModalDef.create("Groom a pet").form(form) 537 .idempotencyKey(IdempotencyKey.mintSelfTargeted(GROOM_JOB).value()).selfTargeted(true); 538 } 539 540 /** 541 * Starts a groom run. 542 * 543 * <p> 544 * Accepts the API form {@code {pet, verbose}} or the dialog form {@code {action, targetId, idempotencyKey, 545 * fields:{pet, verbose}}}; a resubmit with the same {@code idempotencyKey} returns the same run. 546 * 547 * @param json The body. 548 * @return The new run's row. 549 * @throws IOException If the run's temp file cannot be created. 550 */ 551 @RestPost(path="/groom") 552 public ActionResult groom(@Content JsonMap json) throws IOException { 553 var fields = json.getMap("fields"); 554 var src = fields != null ? fields : json; 555 var run = groomRuns().start(src.getString("pet"), isOn(src.get("verbose")), json.getString("idempotencyKey")); 556 return ActionResult.success(run.row()).message(f("Grooming %s", run.pet())); 557 } 558 559 private static boolean isOn(Object v) { 560 var s = v == null ? "" : String.valueOf(v); 561 return "true".equals(s) || "on".equals(s); 562 } 563 564 /** 565 * @return The groom runs, newest first. 566 */ 567 @RestGet(path="/groom-rows") 568 public List<JsonMap> groomRows() { 569 return groomRuns().list().stream().map(GroomRuns.Run::row).toList(); 570 } 571 572 //----------------------------------------------------------------------------------------------------------------- 573 // Row jobs: a key bound to one row, issued by the server and checked on submit. 574 //----------------------------------------------------------------------------------------------------------------- 575 576 /** 577 * @return One row per species: {@code {id, species, available}}. 578 */ 579 @RestGet(path="/species") 580 public List<JsonMap> speciesRows() { 581 return Arrays.stream(Species.values()).map(this::speciesRow).toList(); 582 } 583 584 /** 585 * A species row's detail panel. 586 * 587 * @param id The species name, for example {@code DOG}. 588 * @return {@code {contractVersion, fields:{available, lastRestock}}}. 589 * @throws NotFound If the species is unknown. 590 */ 591 @RestGet(path="/species/{id}") 592 public JsonMap speciesDetail(@Path("id") String id) { 593 var s = species(id); 594 var last = lastRestock.get(s); 595 var fields = new JsonMap().append("available", available(s)).append("lastRestock", last == null ? null : last.toString()); 596 return RowDetail.response(fields); 597 } 598 599 /** 600 * Issues a restock key bound to one species and the caller. 601 * 602 * <p> 603 * A POST, so it sits behind the console's CSRF boundary like every other console write; it changes nothing but the 604 * key ledger. 605 * 606 * @param req The request. 607 * @param id The species name. 608 * @return {@code {contractVersion, action, targetId, idempotencyKey}}. 609 * @throws NotFound If the species is unknown. 610 */ 611 @RestPost(path="/species/{id}/key") 612 public Map<String,Object> speciesKey(RestRequest req, @Path("id") String id) { 613 var s = species(id); 614 return JobKeyLedger.response(jobKeys().issue(RESTOCK_SPECIES, s.name(), ConsoleWrites.actor(req))); 615 } 616 617 /** 618 * Restocks one species from its row's detail panel. 619 * 620 * <p> 621 * The species is the path's {@code {id}}, which must equal the body's {@code targetId}, which the key must be bound 622 * to. The body's {@code fields} supply only {@code count}; any other field (the panel's hidden {@code species} 623 * included) is ignored. A replay of a claimed key returns the same job, or a {@link JobKeyLedger#SPENT_CODE} 624 * refusal once the registry has aged the job out. 625 * 626 * @param req The request. 627 * @param res The response (202, or 409 on a refusal). 628 * @param id The species name. 629 * @param json The action envelope {@code {action, targetId, idempotencyKey, fields:{count}}}. 630 * @return The job ref (202), a 409 refusal, or a 429 {@code app:job-limit} refusal. 631 * @throws NotFound If the species is unknown. 632 * @throws BadRequest If the envelope is malformed or {@code count} is not a whole number 1-50. 633 */ 634 @RestPost(path="/species/{id}/restock") 635 public Object restockSpecies(RestRequest req, RestResponse res, @Path("id") String id, @Content JsonMap json) { 636 var s = species(id); 637 var submit = RowJobSubmit.parse(json); 638 if (! s.name().equals(submit.targetId())) 639 return refused(res, RowJobSubmit.bindingRefusal()); 640 var actor = ConsoleWrites.actor(req); 641 var ledger = jobKeys(); 642 var registry = asyncJobRegistry(); 643 AsyncJob job; 644 // The ledger's own monitor: the verify that reads the claim and the claim itself happen under one lock, so of 645 // two concurrent submits of one key the second sees the first's job and replays it instead of starting it again. 646 synchronized (ledger) { 647 var grant = submit.verify(ledger, RESTOCK_SPECIES, actor).orElse(null); 648 if (grant == null) 649 return refused(res, RowJobSubmit.bindingRefusal()); 650 var claimed = grant.jobId().orElse(null); 651 if (claimed != null) { 652 var r = replay(claimed, registry); 653 res.setStatus(r instanceof ActionResult ? RowJobSubmit.REFUSAL_STATUS : 202); 654 return r; 655 } 656 // After the replay check: a replay answers its job even if this resubmit's fields have changed. 657 var body = new Restock(Map.of(s, count(submit.fields())), grant.key().value(), 0, 658 Math.max(0, Math.min(speciesHold != null ? (int) speciesHold.value().toMillis() : Integer.getInteger(SPECIES_HOLD_PROPERTY, DEFAULT_HOLD_MS), MAX_HOLD_MS))); 659 var created = registry.runningCount() >= Integer.getInteger(LIMIT_PROPERTY, 2) ? null : registry.tryCreate(grant.key()).orElse(null); 660 if (created == null) 661 return limitReached(res, registry.runningCount()); 662 ledger.claim(grant.key(), created.id()); 663 job = created; 664 remember(created, body); 665 pool.execute(() -> run(created, body, () -> restocked(s, body, actor), () -> speciesRow(s))); 666 } 667 res.setStatus(202); 668 return AsyncJobRef.of(job); 669 } 670 671 /** 672 * A claimed key's replay: its job's ref, or a {@link JobKeyLedger#SPENT_CODE} refusal once the registry has reaped 673 * the job. Never a second job: the key was spent on the first. 674 * 675 * @param jobId The job the key was claimed for. 676 * @param registry The registry. 677 * @return The {@link AsyncJobRef}, or the refusal. 678 */ 679 static Object replay(String jobId, AsyncJobRegistry registry) { 680 return registry.get(jobId).<Object>map(AsyncJobRef::of) 681 .orElseGet(() -> ActionResult.refusal(JobKeyLedger.SPENT_CODE).message("This key's job has aged out; fetch a new key")); 682 } 683 684 private List<Pet> restocked(Species s, Restock body, String actor) { 685 var pets = store().restock(body.perSpecies(), "job:restock-species:" + actor); 686 lastRestock.put(s, PetstoreSeed.now()); 687 return pets; 688 } 689 690 /** 691 * A groom run's detail panel. 692 * 693 * @param id The run id. 694 * @return {@code {contractVersion, fields:<the run's row>}}. 695 * @throws NotFound If the run is unknown. 696 */ 697 @RestGet(path="/groom-runs/{id}") 698 public JsonMap groomRunDetail(@Path("id") String id) { 699 return RowDetail.response(groomRun(id).row()); 700 } 701 702 /** 703 * Issues a regroom key bound to one groom run and the caller. 704 * 705 * @param req The request. 706 * @param id The run id. 707 * @return {@code {contractVersion, action, targetId, idempotencyKey}}. 708 * @throws NotFound If the run is unknown. 709 */ 710 @RestPost(path="/groom-runs/{id}/key") 711 public Map<String,Object> groomRunKey(RestRequest req, @Path("id") String id) { 712 var r = groomRun(id); 713 return JobKeyLedger.response(jobKeys().issue(REGROOM, r.id(), ConsoleWrites.actor(req))); 714 } 715 716 /** 717 * Grooms a run's pet again. 718 * 719 * <p> 720 * Ledger keys only, bound to the path's run: the pet comes from the server's run record, never the request. 721 * {@code /groom} stays the dialog's self-targeted endpoint; one endpoint taking both kinds of key would be only as 722 * strong as the self-targeted one. A replay returns the same new run while it is kept, then a 723 * {@link JobKeyLedger#SPENT_CODE} refusal once the run list has evicted it; never a second run. 724 * 725 * @param req The request. 726 * @param res The response (409 on a refusal). 727 * @param id The run id. 728 * @param json The action envelope {@code {action, targetId, idempotencyKey}}. 729 * @return The new run's row, or a 409 refusal. 730 * @throws NotFound If the key is unclaimed and the run is unknown. 731 * @throws BadRequest If the envelope is malformed. 732 * @throws IOException If the new run's temp file cannot be created. 733 */ 734 @RestPost(path="/groom-runs/{id}/regroom") 735 public ActionResult regroom(RestRequest req, RestResponse res, @Path("id") String id, @Content JsonMap json) throws IOException { 736 var submit = RowJobSubmit.parse(json); 737 if (! id.equals(submit.targetId())) 738 return refused(res, RowJobSubmit.bindingRefusal()); 739 var ledger = jobKeys(); 740 // As restockSpecies: the claim is read and recorded under the ledger's lock. The replay check comes before the 741 // run lookup, because GroomRuns evicts the oldest runs first and a replay must still answer once its target is gone. 742 synchronized (ledger) { 743 var grant = submit.verify(ledger, REGROOM, ConsoleWrites.actor(req)).orElse(null); 744 if (grant == null) 745 return refused(res, RowJobSubmit.bindingRefusal()); 746 var claimed = grant.jobId().orElse(null); 747 if (claimed != null) 748 return groomRuns().get(claimed).map(x -> ActionResult.success(x.row()).message(f("Grooming %s again", x.pet()))) 749 .orElseGet(() -> refused(res, ActionResult.refusal(JobKeyLedger.SPENT_CODE).message("This key's run has aged out; fetch a new key"))); 750 var again = groomRuns().start(groomRun(id).pet(), false, grant.key().value()); 751 ledger.claim(grant.key(), again.id()); 752 return ActionResult.success(again.row()).message(f("Grooming %s again", again.pet())); 753 } 754 } 755 756 private static ActionResult refused(RestResponse res, ActionResult refusal) { 757 res.setStatus(RowJobSubmit.REFUSAL_STATUS); 758 return refusal; 759 } 760 761 private void remember(AsyncJob job, Restock body) { 762 synchronized (submitted) { 763 submitted.addFirst(JsonMap.of("jobId", job.id(), "submitted", job.createdAt().toString(), "request", body.perSpecies(), "idempotencyKey", body.idempotencyKey())); 764 while (submitted.size() > MAX_SUBMITTED) 765 submitted.removeLast(); 766 } 767 } 768 769 private JsonMap speciesRow(Species s) { 770 return JsonMap.of("id", s.name(), "species", s.name(), "available", available(s)); 771 } 772 773 private long available(Species s) { 774 return store().getPets().stream().filter(p -> p.getSpecies() == s && p.getStatus() == PetStatus.AVAILABLE).count(); 775 } 776 777 private static Species species(String id) { 778 for (var s : Species.values()) 779 if (s.name().equals(id)) 780 return s; 781 throw new NotFound("Unknown species '%s'", id); 782 } 783 784 private GroomRuns.Run groomRun(String id) { 785 return groomRuns().get(id).orElseThrow(() -> new NotFound("Unknown groom run '%s'", id)); 786 } 787 788 private static int count(JsonMap fields) { 789 var v = fields == null ? null : fields.get("count"); 790 try { 791 var n = v == null ? 0 : Integer.parseInt(String.valueOf(v).trim()); 792 if (n >= 1 && n <= 50) 793 return n; 794 } catch (NumberFormatException e) { 795 // Not a whole number (a decimal included): falls through to the 400 below. 796 } 797 throw new BadRequest("Field 'count' must be a whole number between 1 and 50"); 798 } 799}