Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions completions/ccas.bash
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ _ccas() {
*) COMPREPLY=(); return ;;
esac ;;
use-club) pos="slug" ;;
membership) opts="--server --trust-usernames --no-trust-usernames --club --all --no-progress" ;;
history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all --no-progress" ;;
membership) opts="--server --trust-usernames --no-trust-usernames --club --all --no-progress --detach" ;;
history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all --no-progress --detach" ;;
recruit) opts="--server --alias --target --cumulative --source-clubs --time-limit-minutes --explore --no-explore --club --stdout --report --no-progress" ;;
stats) opts="--server --since --until --club --no-progress" ;;
stats) opts="--server --since --until --club --no-progress --detach" ;;
jobs) opts="--server --limit" ;;
logs) opts="--server --no-progress"; pos="jobid" ;;
cancel) opts="--server"; pos="jobid" ;;
Expand Down
32 changes: 21 additions & 11 deletions src/main/scala/ccas/cli/CliCommand.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ object CliCommand {
clubs: List[String],
all: Boolean,
trustUsernames: Option[Boolean],
noProgress: Boolean
noProgress: Boolean,
detach: Boolean
) extends ServerCommand
final case class History(
server: String,
Expand All @@ -64,7 +65,8 @@ object CliCommand {
includeFinished: Boolean,
refresh: Boolean,
refreshMinHours: Option[Int],
noProgress: Boolean
noProgress: Boolean,
detach: Boolean
) extends ServerCommand
final case class Recruit(
server: String,
Expand All @@ -85,7 +87,8 @@ object CliCommand {
club: Option[String],
since: Option[String],
until: Option[String],
noProgress: Boolean
noProgress: Boolean,
detach: Boolean
) extends ServerCommand
final case class Jobs(server: String, limit: Option[Int]) extends ServerCommand
final case class Logs(server: String, jobId: String, noProgress: Boolean) extends ServerCommand
Expand Down Expand Up @@ -144,6 +147,11 @@ object CliCommand {
private val noProgressOpt: Options[Boolean] =
Options.boolean("no-progress") ?? "Don't render progress bars while following the job (plain log lines only)"

// Fire-and-forget: submit the job and return immediately without following it. Reattach later with `ccas logs <id>`.
// Not offered on `recruit` (its result delivery / invite confirmation needs the follow) or `logs` (which IS the follow).
private val detachOpt: Options[Boolean] =
Options.boolean("detach") ?? "Submit the job and return immediately without following it (reattach with 'ccas logs <id>')"

private def intOpt(name: String): Options[Option[Int]] = Options.integer(name).map(_.toInt).optional

// Single-club target. Absent → Dispatcher falls back to the config's `current_club`.
Expand Down Expand Up @@ -190,10 +198,10 @@ object CliCommand {
.map(Use.apply)

private def membership(default: String): Command[CliCommand] =
Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt ++ noProgressOpt)
Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt ++ noProgressOpt ++ detachOpt)
.withHelp("Submit a membership-sync job (current club, --club a,b, or --all managed clubs)")
.map { case (server, trust, clubs, all, noProgress) =>
Membership(server, clubs, all, trust, noProgress)
.map { case (server, trust, clubs, all, noProgress, detach) =>
Membership(server, clubs, all, trust, noProgress, detach)
}

private def history(default: String): Command[CliCommand] =
Expand All @@ -204,10 +212,10 @@ object CliCommand {
(Options.boolean("include-finished") ?? "Re-queue recently finished matches for refresh") ++
(Options.boolean("refresh") ?? "Refresh already-stored matches, not just newly seen ones") ++
(intOpt("refresh-min-hours") ?? "Skip refreshing a match seen within the last N hours") ++
clubsOpt ++ allOpt ++ noProgressOpt
clubsOpt ++ allOpt ++ noProgressOpt ++ detachOpt
).withHelp("Submit a match-history crawl job (current club, --club a,b, or --all managed clubs)")
.map { case (server, full, includeFinished, refresh, refreshMinHours, clubs, all, noProgress) =>
History(server, clubs, all, full, includeFinished, refresh, refreshMinHours, noProgress)
.map { case (server, full, includeFinished, refresh, refreshMinHours, clubs, all, noProgress, detach) =>
History(server, clubs, all, full, includeFinished, refresh, refreshMinHours, noProgress, detach)
}

private val sourceClubsOpt: Options[List[String]] =
Expand Down Expand Up @@ -243,9 +251,11 @@ object CliCommand {
serverOpt(default) ++
(Options.text("since").optional ?? "Start of the date window (ISO-8601 date or instant)") ++
(Options.text("until").optional ?? "End of the date window (requires --since)") ++
clubOpt ++ noProgressOpt
clubOpt ++ noProgressOpt ++ detachOpt
).withHelp("Submit a club performance-stats job")
.map { case (server, since, until, club, noProgress) => Stats(server, club, since, until, noProgress) }
.map { case (server, since, until, club, noProgress, detach) =>
Stats(server, club, since, until, noProgress, detach)
}

private def jobs(default: String): Command[CliCommand] =
Command("jobs", serverOpt(default) ++ (intOpt("limit") ?? "Maximum number of recent jobs to list"))
Expand Down
7 changes: 4 additions & 3 deletions src/main/scala/ccas/cli/CompletionSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ object CompletionSpec {
Leaf(List("use-club"), Nil, Nil, Slug),
Leaf(
List("membership"),
List(server, "--trust-usernames", "--no-trust-usernames", clubFlag, "--all", "--no-progress"),
List(server, "--trust-usernames", "--no-trust-usernames", clubFlag, "--all", "--no-progress", "--detach"),
List(server, clubFlag),
NoArgs
),
Leaf(
List("history"),
List(server, "--full", "--include-finished", "--refresh", "--refresh-min-hours", clubFlag, "--all", "--no-progress"),
List(server, "--full", "--include-finished", "--refresh", "--refresh-min-hours", clubFlag, "--all",
"--no-progress", "--detach"),
List(server, "--refresh-min-hours", clubFlag),
NoArgs
),
Expand All @@ -71,7 +72,7 @@ object CompletionSpec {
),
Leaf(
List("stats"),
List(server, "--since", "--until", clubFlag, "--no-progress"),
List(server, "--since", "--until", clubFlag, "--no-progress", "--detach"),
List(server, "--since", "--until", clubFlag),
NoArgs
),
Expand Down
42 changes: 33 additions & 9 deletions src/main/scala/ccas/cli/Dispatcher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,19 @@ object Dispatcher {
cmd: CliCommand.ServerCommand,
currentClub: Option[String]
): Task[Int] = cmd match {
case CliCommand.Membership(_, clubs, all, trust, _) =>
case CliCommand.Membership(_, clubs, all, trust, _, detach) =>
resolveClubs(api, clubs, all, currentClub).flatMap(slugs =>
followEachClub(follower, slugs)(slug =>
followEachClub(follower, slugs, detach)(slug =>
api.postJson[MembershipRequest, List[ClubJobResult]](
"/api/jobs/membership",
MembershipRequest(NonEmptyChunk.single(slug), trust)
)
)
)

case CliCommand.History(_, clubs, all, full, includeFinished, refresh, refreshMinHours, _) =>
case CliCommand.History(_, clubs, all, full, includeFinished, refresh, refreshMinHours, _, detach) =>
resolveClubs(api, clubs, all, currentClub).flatMap(slugs =>
followEachClub(follower, slugs)(slug =>
followEachClub(follower, slugs, detach)(slug =>
api.postJson[HistoryRequest, List[ClubJobResult]](
"/api/jobs/history",
HistoryRequest(NonEmptyChunk.single(slug), flag(full), flag(includeFinished), flag(refresh), refreshMinHours)
Expand Down Expand Up @@ -142,12 +142,15 @@ object Dispatcher {
)
}

case CliCommand.Stats(_, club, since, until, _) =>
case CliCommand.Stats(_, club, since, until, _, detach) =>
resolveClub(club, currentClub).flatMap(slug =>
api.postJson[StatsRequest, ClubJobResult](
"/api/jobs/stats",
StatsRequest(slug, since, until)
).flatMap(follower.handleClubSingle)
).flatMap(result =>
if (detach) { reportDetachedClub(result) }
else { follower.handleClubSingle(result) }
)
)

case CliCommand.Jobs(_, limit) =>
Expand Down Expand Up @@ -227,19 +230,40 @@ object Dispatcher {
// `submitOne` posts a single-club job (the batch routes accept a one-element list) whose result `handleBatch` follows.
// Near-free vs concurrent submission: the shared Chess.com client is gate-bound, so total wall-clock is ~unchanged.
//
// With `detach` (#170) the follow is skipped: each club's job is submitted, its id printed, and the command returns —
// no log stream, no Ctrl-C-cancel. Reattach later with `ccas logs <id>`. Serialization is irrelevant then (nothing is
// streamed), but the same per-club loop is reused so error handling and scoring stay identical.
//
// Each club is best-effort: a submit / transport failure (a 4xx/5xx or a dropped connection for one club) is caught,
// reported, and scored as a failure for that club WITHOUT aborting the rest — matching `handleBatch`'s handling of
// per-club errors in a 200 body, and preserving the old batch design's "every club still runs" property.
private def followEachClub(follower: JobFollower, slugs: NonEmptyChunk[ClubSlug])(
private def followEachClub(follower: JobFollower, slugs: NonEmptyChunk[ClubSlug], detach: Boolean)(
submitOne: ClubSlug => Task[List[ClubJobResult]]
): Task[Int] =
): Task[Int] = {
val handle: List[ClubJobResult] => Task[Int] =
if (detach) { results =>
ZIO.foreach(results)(reportDetachedClub).map(codes => if (codes.forall(_ == 0)) { 0 } else { 1 })
} else { follower.handleBatch }
ZIO
.foreach(slugs.toChunk.toList)(slug =>
submitOne(slug)
.flatMap(follower.handleBatch)
.flatMap(handle)
.catchAll(e => Console.printLineError(s"${ClubSlug.unwrap(slug)}: ${rootMessage(e)}").orDie.as(1))
)
.map(codes => if (codes.forall(_ == 0)) { 0 } else { 1 })
}

// Detached submit of a single club-scoped job (#170): print its id (or its submit error) and DON'T follow. Cache the
// id for `ccas logs`/`ccas cancel` completion, and note the reattach command. Exit 1 for a per-club submit failure so
// a partial `--all --detach` batch still scores as failed overall (mirrors `handleClubSingle`'s error scoring).
private def reportDetachedClub(result: ClubJobResult): UIO[Int] =
(result.error, result.jobId) match {
case (Some(err), _) => Console.printLineError(s"${result.clubSlug}: $err").orDie.as(1)
case (_, Some(id)) =>
CompletionCache.appendJob(id) *>
Console.printLine(s"${result.clubSlug} submitted (detached): $id — follow with 'ccas logs $id'").orDie.as(0)
case _ => Console.printLineError(s"${result.clubSlug}: server returned no job id").orDie.as(1)
}

// Resolution lives in the pure, testable `ClubResolver`; the `--all` expansion's network call is injected here.
private def resolveClub(explicit: Option[String], currentClub: Option[String]): IO[CliError, ClubSlug] =
Expand Down
24 changes: 22 additions & 2 deletions src/main/scala/ccas/cli/JobFollower.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package ccas.cli

import zio.*

import ccas.server.routes.JobRoutes.{ClubJobResult, JobResult, JobStatusResponse}
import ccas.server.routes.JobRoutes.{CancelResult, ClubJobResult, JobResult, JobStatusResponse}

/** Drives a submitted job to completion by **following its log stream** (`GET /api/jobs/{id}/logs`), printing each
* line as it arrives. The server holds the response open until the job is terminal and the tail reaches EOF, so the
Expand Down Expand Up @@ -70,12 +70,32 @@ final class JobFollower(
// Run the log follow (reconnecting across drops, bounded by `maxWait`) and return the raw outcome without printing any
// terminal status: Some(Right)=EOF/terminal, Some(Left)=gave up after maxReconnects, None=timed out. `notice` sinks the
// one-time reconnect message — stderr for the plain follow, `renderer.logLine` when bars are drawn.
//
// Ctrl-C during a follow cancels the server job (#170): the `.onInterrupt` is attached OUTSIDE `.timeout`, so a genuine
// `maxWait` expiry (which internally interrupts `follow` and returns `None` on the main path) does NOT trip it — only a
// real fiber interruption of this whole effect does. Both the plain and bars follow paths route through here, so the
// cancel hook covers both. It fires while the CLI's `Client` layer scope is still open (the follower runs inside it and
// finalizers unwind inner-to-outer), so the POST lands during teardown. Not scoped over `interpret`: once the stream
// reaches EOF the job is already terminal, so a Ctrl-C during the final status read has nothing to cancel.
private def followResult(
jobId: String,
onLine: String => UIO[Unit],
notice: String => UIO[Unit]
): Task[Option[Either[String, Unit]]] =
Ref.make(0).flatMap(printed => follow(jobId, onLine, printed, notice).timeout(maxWait))
Ref.make(0).flatMap(printed =>
follow(jobId, onLine, printed, notice).timeout(maxWait).onInterrupt(cancelOnInterrupt(jobId, notice))
)

// Best-effort cancel of the followed job when the follow is interrupted (Ctrl-C). The notice routes through the
// follow's `notice` sink — the render-lock-safe `renderer.logLine` in bars mode, stderr otherwise — so it can't tear a
// live bar line (this finalizer runs BEFORE `withBars`' `renderer.clear`, which is the outer `.onExit`). It emits
// first so the operator sees it even if the POST is slow or fails; the POST itself is `.ignore`d — a 404 (the job
// already reached a terminal state before the interrupt landed) or a transport error during teardown must not turn a
// clean Ctrl-C into a stack trace. Cancellation is best-effort/asynchronous server-side (an in-flight blocking
// statement runs to completion first), so this requests the cancel and returns — it does not wait for `Cancelled`.
private def cancelOnInterrupt(jobId: String, notice: String => UIO[Unit]): UIO[Unit] =
notice(s"$jobId: cancelling on interrupt…") *>
api.postEmpty[CancelResult](s"/api/jobs/$jobId/cancel").ignore

// Map the follow outcome to an exit code, printing the terminal status. In bars mode this runs AFTER the bars are
// cleared (see `withBars`), so the message can't be jammed onto a live bar line.
Expand Down
22 changes: 16 additions & 6 deletions src/main/scala/ccas/server/jobs/JobRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,23 @@ object JobRunner {
trigger: RunTrigger,
effect: Option[JobRunId] => RIO[ProgressDisplay & ChessComClient & PostgresClient, Any]
): RIO[PostgresClient, JobRunId] = {
def conflictError = ConflictException(
s"A $kind job is already running" + clubId.fold("")(c => s" for club $c")
)
// `cancelling` distinguishes a blocker that an operator has already asked to cancel (its id is in `cancelRequested`)
// but whose interrupt hasn't landed yet — an in-flight blocking statement runs to completion before the fiber
// flips to `Cancelled` and `release` clears it from the set. Without this, a `ccas ... ` immediately after a
// Ctrl-C cancel of the same (kind, club) reads the baffling "already running" for a job the operator just killed;
// the "finishing cancellation — retry in a moment" wording tells them it is self-resolving. (#170)
def conflictError(cancelling: Boolean): ConflictException = {
val forClub = clubId.fold("")(c => s" for club $c")
if (cancelling) { ConflictException(s"A $kind job$forClub is finishing cancellation — retry in a moment") }
else { ConflictException(s"A $kind job is already running$forClub") }
}
(for {
id <- withTransaction {
for {
existing <- JobRun.selectRunningForUpdate(kind, clubId)
_ <- ZIO.whenDiscard(existing.isDefined)(ZIO.fail(conflictError))
_ <- ZIO.foreachDiscard(existing)(job =>
cancelRequested.get.flatMap(pending => ZIO.fail(conflictError(pending.contains(job.id))))
)
id = JobRunId.generate()
now <- Clock.instant
jobRun = JobRun(id, kind, clubId, trigger, JobRunStatus.Running, params, now, None, None)
Expand Down Expand Up @@ -227,8 +236,9 @@ object JobRunner {
}
} yield id).catchSome {
// Phantom-row race: two transactions both saw no running job, the unique partial index
// on (kind, COALESCE(club_id, -1)) WHERE status = 'Running' caught the second insert.
case e: SQLException if e.getSQLState == "23505" => ZIO.fail(conflictError)
// on (kind, COALESCE(club_id, -1)) WHERE status = 'Running' caught the second insert. A genuine concurrent
// submit (not a cancel-in-flight), so the plain "already running" wording is right.
case e: SQLException if e.getSQLState == "23505" => ZIO.fail(conflictError(cancelling = false))
}
}

Expand Down
Loading