fun_ci 2.2.0-aarch64-linux → 2.4.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +19 -11
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/commands.rb +3 -1
  6. data/lib/fun_ci/agent/job_events.rb +26 -7
  7. data/lib/fun_ci/agent/job_json.rb +10 -1
  8. data/lib/fun_ci/agent/job_report.rb +10 -6
  9. data/lib/fun_ci/agent/job_why_text.rb +11 -3
  10. data/lib/fun_ci/agent/jobs_text.rb +8 -3
  11. data/lib/fun_ci/agent/live_pipeline.rb +7 -4
  12. data/lib/fun_ci/agent/snapshots.rb +5 -1
  13. data/lib/fun_ci/agent/starts_in.rb +14 -0
  14. data/lib/fun_ci/agent/status_json.rb +1 -1
  15. data/lib/fun_ci/agent/status_text.rb +15 -3
  16. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  17. data/lib/fun_ci/agent/wait_command.rb +2 -1
  18. data/lib/fun_ci/cli.rb +3 -1
  19. data/lib/fun_ci/cli_command_help.rb +51 -0
  20. data/lib/fun_ci/cli_help.rb +11 -10
  21. data/lib/fun_ci/console/board_data.rb +7 -2
  22. data/lib/fun_ci/console/job_message.rb +12 -6
  23. data/lib/fun_ci/console/job_order.rb +5 -4
  24. data/lib/fun_ci/console/key_handler.rb +3 -1
  25. data/lib/fun_ci/console/trunk_marks.rb +3 -1
  26. data/lib/fun_ci/evidence/about_stage.rb +1 -1
  27. data/lib/fun_ci/evidence/collector.rb +6 -3
  28. data/lib/fun_ci/evidence/outcome.rb +6 -3
  29. data/lib/fun_ci/evidence/settings.rb +1 -1
  30. data/lib/fun_ci/jobs/due.rb +4 -3
  31. data/lib/fun_ci/jobs/due_jobs.rb +26 -2
  32. data/lib/fun_ci/jobs/job_fork.rb +19 -12
  33. data/lib/fun_ci/jobs/job_run.rb +26 -7
  34. data/lib/fun_ci/jobs/locks.rb +1 -1
  35. data/lib/fun_ci/jobs/schedule.rb +25 -0
  36. data/lib/fun_ci/jobs/site.rb +10 -3
  37. data/lib/fun_ci/jobs/standings.rb +4 -0
  38. data/lib/fun_ci/jobs/state.rb +3 -2
  39. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  40. data/lib/fun_ci/persistence/active_jobs.rb +15 -18
  41. data/lib/fun_ci/persistence/active_runs.rb +5 -3
  42. data/lib/fun_ci/persistence/died.rb +21 -0
  43. data/lib/fun_ci/persistence/job_recorder.rb +1 -0
  44. data/lib/fun_ci/persistence/job_runs.rb +12 -0
  45. data/lib/fun_ci/persistence/null_recorder.rb +1 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +13 -9
  48. data/lib/fun_ci/persistence/stage_job.rb +12 -0
  49. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  50. data/lib/fun_ci/pipeline/group_kill.rb +38 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +14 -9
  52. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  53. data/lib/fun_ci/pipeline/process_runner.rb +13 -6
  54. data/lib/fun_ci/pipeline/process_tree.rb +37 -0
  55. data/lib/fun_ci/pipeline/progress_reporter.rb +5 -0
  56. data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
  57. data/lib/fun_ci/pipeline/slot.rb +11 -0
  58. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  59. data/lib/fun_ci/pipeline/stage_end.rb +2 -1
  60. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  61. data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
  62. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  63. data/lib/fun_ci/pipeline/workspaces.rb +2 -1
  64. data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
  65. data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
  66. data/lib/fun_ci/pipeline/worktrees.rb +39 -6
  67. data/lib/fun_ci/setup/agent_instructions.rb +21 -5
  68. data/lib/fun_ci/setup/commands.rb +19 -8
  69. data/lib/fun_ci/setup/eslint_detection.rb +38 -0
  70. data/lib/fun_ci/setup/hook_script.rb +9 -4
  71. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  72. data/lib/fun_ci/setup/init_options.rb +34 -0
  73. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  74. data/lib/fun_ci/setup/installer.rb +44 -9
  75. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  76. data/lib/fun_ci/setup/placeholders.rb +30 -0
  77. data/lib/fun_ci/setup/project_config.rb +8 -1
  78. data/lib/fun_ci/setup/settings.rb +59 -10
  79. data/lib/fun_ci/setup/setup_checker.rb +16 -3
  80. data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
  81. data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
  82. data/lib/fun_ci/setup/stage_templates.rb +6 -4
  83. data/lib/fun_ci/setup/template_writer.rb +3 -5
  84. data/lib/fun_ci/trunk/shown.rb +7 -4
  85. data/lib/fun_ci/version.rb +1 -1
  86. data/libexec/fun-ci-renderer +0 -0
  87. metadata +17 -1
@@ -5,6 +5,8 @@ require_relative "verdict"
5
5
  require_relative "digest"
6
6
  require_relative "trunk_text"
7
7
  require_relative "job_report"
8
+ require_relative "starts_in"
9
+ require_relative "../pipeline/budgets"
8
10
 
9
11
  module FunCi
10
12
  module Agent
@@ -29,10 +31,13 @@ module FunCi
29
31
  # ` soak (weekly job) FAILED fun-ci why --job soak`; `where` names the
30
32
  # commit it tested when that is another.
31
33
  def self.job_line(job, where = "")
32
- said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{where}"
34
+ said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{starts(job)}#{where}"
33
35
  job.needs_you? ? "#{said} fun-ci why --job #{job.name}" : said
34
36
  end
35
37
 
38
+ # `, starts in 8m` for a job waiting its turn.
39
+ def self.starts(job) = job.starts_in ? ", #{StartsIn.words(job.starts_in)}" : ""
40
+
36
41
  # The trunk lines; nothing while the check is going, unless asked about
37
42
  # (right after a commit it would say nothing useful).
38
43
  def self.trunk(report, asked)
@@ -45,10 +50,17 @@ module FunCi
45
50
 
46
51
  def self.stage_line(stage, needed)
47
52
  seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
48
- note = needed.include?(stage.name) ? "" : " (not needed)"
53
+ note = (needed.include?(stage.name) ? "" : " (not needed)") + longer(stage)
49
54
  " #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
50
55
  end
51
56
 
57
+ # Only a cold worktree (WorktreePool) gives a stage more than its usual budget
58
+ # (AT-1.14), which a run in the background says nowhere else.
59
+ def self.longer(stage)
60
+ usual = Pipeline::DEFAULT_BUDGETS[stage.name]
61
+ stage.budget && usual && stage.budget > usual ? " (cold worktree, budget #{stage.budget}s)" : ""
62
+ end
63
+
52
64
  def self.footer(report)
53
65
  return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
54
66
  return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
@@ -56,7 +68,7 @@ module FunCi
56
68
 
57
69
  ["Superseded by #{report.superseded_by[0, 7]}."]
58
70
  end
59
- private_class_method :stage_line, :footer, :job_lines, :job_line
71
+ private_class_method :stage_line, :longer, :footer, :job_lines, :job_line, :starts
60
72
  end
61
73
  end
62
74
  end
@@ -28,7 +28,8 @@ module FunCi
28
28
  return shown(check) if check
29
29
 
30
30
  started = run[:trunk_started_at]
31
- started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now)
31
+ started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now,
32
+ stages_running: !run[:trigger_pid].nil?)
32
33
  end
33
34
 
34
35
  # Checks the commit against where the trunk is now, without fetching, and keeps the check,
@@ -52,9 +52,10 @@ module FunCi
52
52
  ExitCode::FOR.fetch(report.verdict)
53
53
  end
54
54
 
55
+ # A waiter keeps its run from being superseded, unless it follows the branch to the newer commit.
55
56
  def poll(sha, options)
56
57
  @context.pipeline.watch(@context.db)
57
- reports.mark_waited(sha, @context.clock.now)
58
+ reports.mark_waited(sha, @context.clock.now) unless options.follow_branch
58
59
  report_for(sha, options) || start_after_grace(sha)
59
60
  end
60
61
 
data/lib/fun_ci/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../fun_ci"
4
4
  require_relative "cli_help"
5
+ require_relative "cli_command_help"
5
6
  require_relative "pipeline/trigger_params"
6
7
  require_relative "persistence/state_dir"
7
8
  require_relative "setup/commands"
@@ -17,7 +18,7 @@ module FunCi
17
18
  "prune" => :run_prune,
18
19
  "extract" => :run_extract
19
20
  }.freeze
20
- AGENT_COMMANDS = %w[status runs wait events why jobs].freeze
21
+ AGENT_COMMANDS = %w[status runs wait events why jobs cancel].freeze
21
22
 
22
23
  def self.default_db_dir = Persistence::StateDir.path(ENV)
23
24
 
@@ -37,6 +38,7 @@ module FunCi
37
38
  return CliHelp.version(@io.stdout) if subcommand == "--version"
38
39
 
39
40
  return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
41
+ return CliCommandHelp.help(@io.stdout, subcommand) if CliCommandHelp.asked?(args.drop(1))
40
42
 
41
43
  dispatch(subcommand, args.drop(1))
42
44
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_help"
4
+
5
+ module FunCi
6
+ # What `fun-ci COMMAND --help` prints in place of running the command: its
7
+ # usage, what it is for and the options it takes, the last two read from
8
+ # `fun-ci --help`, whose options name in brackets the commands taking them.
9
+ module CliCommandHelp
10
+ FLAGS = %w[-h --help].freeze
11
+ USAGES = {
12
+ "trigger" => "fun-ci trigger [--background] <commit-hash> <branch>",
13
+ "console" => "fun-ci console",
14
+ "init" => "fun-ci init [--everything] [--stack NAME]",
15
+ "install-hooks" => "fun-ci install-hooks [post-commit | pre-push]",
16
+ "check" => "fun-ci check",
17
+ "prune" => "fun-ci prune",
18
+ "status" => "fun-ci status [REV] [--need LEVEL] [--trunk] [--json]",
19
+ "wait" => "fun-ci wait [REV] [--need LEVEL] [--within TIME] [--follow-branch] [--trunk] [--json]",
20
+ "runs" => "fun-ci runs [-n N] [--branch NAME] [--json]",
21
+ "events" => "fun-ci events [--follow] [--only failures]",
22
+ "why" => "fun-ci why [REV] [STAGE | trunk] [--need LEVEL] [--raw] [--json]\n " \
23
+ "fun-ci why --job NAME [--raw] [--json]",
24
+ "jobs" => "fun-ci jobs [--json]",
25
+ "cancel" => "fun-ci cancel --job NAME",
26
+ "extract" => "fun-ci extract STAGE --output FILE [--exit N | --timed-out] [--json]"
27
+ }.freeze
28
+
29
+ def self.asked?(args) = args.any? { |arg| FLAGS.include?(arg) }
30
+
31
+ def self.help(out, command)
32
+ out.puts "Usage: #{USAGES.fetch(command)}", "", purpose(command)
33
+ options = options_of(command)
34
+ out.puts "", "Options:", *options unless options.empty?
35
+ 0
36
+ end
37
+
38
+ def self.purpose(command) = CliHelp::TEXT[/^ #{Regexp.escape(command)} +(.+)$/, 1]
39
+
40
+ def self.options_of(command)
41
+ lines = CliHelp::TEXT.lines.select { |line| takes?(line, command) }
42
+ lines.map { |line| line.sub(/\([^)]+\) /, "") }
43
+ end
44
+
45
+ def self.takes?(option_line, command)
46
+ commands = option_line[/^\s+-\S.*?\(([^)]+)\)/, 1]
47
+ !commands.nil? && commands.split(", ").include?(command)
48
+ end
49
+ private_class_method :purpose, :options_of, :takes?
50
+ end
51
+ end
@@ -32,33 +32,34 @@ module FunCi
32
32
  events Print this project's runs' events as JSON lines
33
33
  why Print everything kept about why a commit's stage failed, or its conflict with the trunk
34
34
  jobs List this project's daily and weekly jobs and how each stands
35
+ cancel Cancel a daily or weekly job's run: cancel --job NAME
35
36
  extract Try a stage's evidence extractors on a saved output
36
37
 
37
38
  Options:
38
- -h, --help Show this help message
39
+ -h, --help Show this help message; after a command, that command's usage
39
40
  --version Show version
40
41
 
41
- Trigger options:
42
- --background Run the pipeline in the background and return at once
43
-
44
- Agent options:
42
+ Command options:
43
+ --background (trigger) Run the pipeline in the background and return at once
44
+ --everything (init) Run init + install-hooks + check in one step
45
+ --stack NAME (init) Write the scripts for this stack, such as go, instead of the one detected
45
46
  --need LEVEL (status, wait, why) What must pass: build, fast (default) or all
46
47
  --within TIME (wait) Give up undecided after 30, 30s or 5m
47
48
  --follow-branch (wait) Move on to the newer commit that superseded the run
48
49
  --trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
49
50
  --raw (why) Print the stage's whole output as it was kept
50
- --job NAME (why) Explain the daily or weekly job's latest run instead of a commit's stage
51
+ --job NAME (why, cancel) The daily or weekly job to explain or cancel, instead of a commit
51
52
  -n N (runs) How many runs to list (default 10)
52
53
  --branch NAME (runs) Only runs on this branch
53
54
  --follow (events) Keep printing events as they happen, until stopped
54
55
  --only failures (events) Print only failed stages, superseded runs and failed jobs
55
- --json Print JSON instead of text (events always print JSON lines)
56
+ --output FILE (extract) The saved output of the stage to read
57
+ --exit N (extract) The exit status the stage ended with (default 1)
58
+ --timed-out (extract) The stage ran out of time instead of exiting
59
+ --json (status, wait, runs, why, jobs, extract) Print JSON instead of text; events always print JSON lines
56
60
  Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
57
61
  4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
58
62
 
59
- Init options:
60
- --everything Run init + install-hooks + check in one step
61
-
62
63
  Examples:
63
64
  fun-ci init --everything
64
65
  fun-ci trigger abc1234 main
@@ -19,6 +19,9 @@ module FunCi
19
19
  # How many of a branch's runs are read to fold its cancelled ones and
20
20
  # find its standing against the trunk.
21
21
  BRANCH_WINDOW = 100
22
+ # How many branches the board holds at most: those whose newest run
23
+ # changed most recently, so old failures leave it in time.
24
+ RECENT_BRANCHES = 20
22
25
 
23
26
  # `clock` answers the time now, which decides which jobs are due.
24
27
  def initialize(db, page_size: 15, run_canceller: Pipeline::RunCanceller.new, clock: -> { Time.now })
@@ -34,7 +37,7 @@ module FunCi
34
37
  def resize(page_size) = @paging.resize(page_size)
35
38
 
36
39
  # Whether there are branches beyond those `runs` loads.
37
- def more? = Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit + 1).size > @paging.limit
40
+ def more? = heads(@paging.limit + 1).size > @paging.limit
38
41
 
39
42
  # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
40
43
  def record_dead_slow_suites = @run_canceller.record_dead(@db)
@@ -55,7 +58,7 @@ module FunCi
55
58
  # and then those run most recently, in RowOrder: a run of cancelled
56
59
  # runs folded into one, with its stages and the branch's standing against the trunk.
57
60
  def runs
58
- RowOrder.of(Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit).map { |head| branch_row(head) })
61
+ RowOrder.of(heads(@paging.limit).map { |head| branch_row(head) })
59
62
  end
60
63
 
61
64
  # The projects among `runs` whose trunk is stale.
@@ -74,6 +77,8 @@ module FunCi
74
77
 
75
78
  private
76
79
 
80
+ def heads(limit) = Persistence::PipelineRun.branch_heads(@db, limit: limit, among: RECENT_BRANCHES)
81
+
77
82
  def branch_row(head)
78
83
  branch_runs = Persistence::PipelineRun.of_branch(@db, head[:project_path], head[:branch], limit: BRANCH_WINDOW)
79
84
  row = CancelledFolding.fold(branch_runs).first
@@ -14,20 +14,26 @@ module FunCi
14
14
 
15
15
  def self.from(standing)
16
16
  { project: standing.project, name: standing.name, cadence: standing.cadence,
17
- status: STATUS.fetch(standing.state, standing.state), **run(standing.run),
17
+ status: STATUS.fetch(standing.state, standing.state), **run(standing.run, standing.starts_at),
18
18
  due_at: standing.due_at&.to_i }.compact
19
19
  end
20
20
 
21
- # What the latest run says: which, on what, and when.
22
- def self.run(run)
21
+ # What the latest run says: which, on what, and when; a run waiting its
22
+ # turn (Jobs::Schedule) has not started, and says when it starts.
23
+ def self.run(run, starts_at)
23
24
  return {} unless run
24
25
 
25
- { run_id: run[:id], sha: run[:commit_hash], branch: run[:branch], started_at: epoch(run[:started_at]),
26
- updated_at: epoch(run[:completed_at] || run[:started_at]) }
26
+ { run_id: run[:id], sha: run[:commit_hash], branch: run[:branch], **times(run, starts_at) }
27
+ end
28
+
29
+ def self.times(run, starts_at)
30
+ return { starts_at: starts_at.to_i } if starts_at
31
+
32
+ { started_at: epoch(run[:started_at]), updated_at: epoch(run[:completed_at] || run[:started_at]) }
27
33
  end
28
34
 
29
35
  def self.epoch(iso) = Time.parse(iso).to_i
30
- private_class_method :run, :epoch
36
+ private_class_method :run, :times, :epoch
31
37
  end
32
38
  end
33
39
  end
@@ -3,14 +3,15 @@
3
3
  module FunCi
4
4
  module Console
5
5
  # The order of the job section's rows (design.md, The console): what
6
- # needs you first, then running, due and passed; among equals, the
6
+ # needs you first, then running, waiting its turn to (Jobs::Schedule),
7
+ # due and passed; among equals, the
7
8
  # board's projects in its order, then each project's jobs by name. Rows
8
9
  # answer #state (Jobs::State), #project and #name.
9
10
  module JobOrder
10
- URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "due" => 3, "cancelled" => 3,
11
- "passed" => 4 }.freeze
11
+ URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "scheduled" => 3, "due" => 4,
12
+ "cancelled" => 4, "passed" => 5 }.freeze
12
13
  # A state this fun-ci doesn't know, which a newer one sharing the database may have written.
13
- UNKNOWN = 5
14
+ UNKNOWN = 6
14
15
 
15
16
  def self.of(rows, projects:)
16
17
  rows.sort_by do |row|
@@ -67,8 +67,10 @@ module FunCi
67
67
  @confirm_cancel = [:run, run] if run[:status] == "running"
68
68
  end
69
69
 
70
- # A running job (a Jobs::Standing) once the user confirms.
70
+ # A job (a Jobs::Standing) waiting its turn is cancelled at once; a
71
+ # running one once the user confirms.
71
72
  def offer_job_cancel(job)
73
+ cancel(:job, job) if job.state == "scheduled"
72
74
  @confirm_cancel = [:job, job] if job.state == "running"
73
75
  end
74
76
 
@@ -44,10 +44,12 @@ module FunCi
44
44
 
45
45
  def stale_since(project, now)
46
46
  last = Persistence::TrunkFetches.new(@db, project).last
47
- return nil unless last && (last.error || now - last.fetched_at > Trunk::Shown::FRESH_FOR)
47
+ return nil unless last && (last.error || old?(last.fetched_at, now))
48
48
 
49
49
  { project: project, since: last.fetched_at&.to_i }
50
50
  end
51
+
52
+ def old?(fetched_at, now) = !fetched_at.nil? && now - fetched_at > Trunk::Shown::FRESH_FOR
51
53
  end
52
54
  end
53
55
  end
@@ -10,7 +10,7 @@ module FunCi
10
10
  # command to read.
11
11
  module AboutStage
12
12
  def self.of(sources, outcome, output)
13
- About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
13
+ About.new(**outcome.to_h.except(:overrun, :elsewhere), **started(sources), **where(sources, output),
14
14
  stage: sources.stage, budget: sources.budget, commit: sources.commit)
15
15
  end
16
16
 
@@ -37,7 +37,7 @@ module FunCi
37
37
  def collect(output, outcome = FAILED)
38
38
  result = extracted(output, outcome)
39
39
  overrun = outcome.overrun || NOTHING
40
- parts = [["fun-ci", own_facts(outcome.alongside)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
40
+ parts = [["fun-ci", own_facts(outcome)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
41
41
  Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
42
42
  end
43
43
 
@@ -76,10 +76,13 @@ module FunCi
76
76
 
77
77
  def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
78
78
 
79
- def own_facts(alongside)
80
- Findings.new(facts: alongside.empty? ? [] : [{ name: "alongside", value: alongside.join(", ") }])
79
+ def own_facts(outcome)
80
+ named = { "alongside" => outcome.alongside, "other runs alongside" => outcome.elsewhere }
81
+ Findings.new(facts: named.reject { |_, stages| stages.empty? }.map { |name, stages| fact(name, stages) })
81
82
  end
82
83
 
84
+ def fact(name, stages) = { name: name, value: stages.join(", ") }
85
+
83
86
  def tail(output)
84
87
  text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
85
88
  Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
@@ -4,12 +4,15 @@ module FunCi
4
4
  module Evidence
5
5
  # How a failed stage ended, known once it has: its state in the words
6
6
  # `status --json` uses, its exit status or signal, and the other stages
7
- # that shared its slot while it ran, and for an overrun, what was found
7
+ # that shared its slot while it ran, the stages of the project's other runs
8
+ # that ran meanwhile (elsewhere), and for an overrun, what was found
8
9
  # before the kill (an Extraction::Result).
9
- Outcome = Data.define(:state, :exit_status, :signal, :alongside, :overrun)
10
+ Outcome = Data.define(:state, :exit_status, :signal, :alongside, :elsewhere, :overrun)
10
11
 
11
12
  class Outcome
12
- def initialize(**given) = super(exit_status: nil, signal: nil, alongside: [], overrun: nil, **given)
13
+ def initialize(**given)
14
+ super(exit_status: nil, signal: nil, alongside: [], elsewhere: [], overrun: nil, **given)
15
+ end
13
16
  end
14
17
  end
15
18
  end
@@ -18,7 +18,7 @@ module FunCi
18
18
 
19
19
  # The settings in the config file at `path`, or the defaults without one.
20
20
  def self.load(path)
21
- new(File.exist?(path) ? (YAML.safe_load_file(path) || {})["evidence"] : nil)
21
+ new(File.exist?(path) ? (YAML.safe_load_file(path, aliases: true) || {})["evidence"] : nil)
22
22
  rescue Psych::Exception, NoMethodError, TypeError
23
23
  new(nil)
24
24
  end
@@ -6,8 +6,8 @@ module FunCi
6
6
  module Jobs
7
7
  # Whether a job is due, from its latest run alone ({ status:, started_at: },
8
8
  # or nil when it never ran): when it never ran, when that run was
9
- # cancelled, or a period after that run started, unless it still runs
10
- # (design.md, Daily and weekly jobs).
9
+ # cancelled, or a period after that run started, unless it still runs or
10
+ # waits to (design.md, Daily and weekly jobs).
11
11
  class Due
12
12
  def initialize(latest, period, now:)
13
13
  @latest = latest
@@ -26,7 +26,8 @@ module FunCi
26
26
 
27
27
  private
28
28
 
29
- def running? = @latest[:status] == "running"
29
+ # A run waiting its turn (Schedule) counts as running: it is the job's run.
30
+ def running? = %w[running scheduled].include?(@latest[:status])
30
31
  def again = Time.parse(@latest[:started_at]) + @period
31
32
  end
32
33
  end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require_relative "standings"
5
+ require_relative "schedule"
4
6
  require_relative "../pipeline/run_canceller"
7
+ require_relative "../setup/project_config"
5
8
 
6
9
  module FunCi
7
10
  module Jobs
@@ -11,15 +14,36 @@ module FunCi
11
14
  # recorded failed first, or it would never be due again, and no process
12
15
  # would start to find it dead.
13
16
  class DueJobs
17
+ BEGUN = %w[running scheduled].freeze
18
+ DAY = 86_400
19
+
14
20
  def initialize(project, db, now:)
15
21
  @project = project
16
22
  @db = db
17
23
  @now = now
18
24
  end
19
25
 
20
- def list
26
+ def list = standings.select(&:due_now?).map(&:job)
27
+
28
+ # [job, when its turn to start comes] for each due job (Schedule): a day
29
+ # shared evenly among the project's jobs apart, or its `job_spacing`,
30
+ # after its jobs still running or waiting to. Each is due again a
31
+ # period after its own start, so the spread holds from day to day.
32
+ def starts
33
+ all = standings
34
+ Schedule.new(spacing(all.size), begun(all), now: @now).starts(all.select(&:due_now?).map(&:job))
35
+ end
36
+
37
+ private
38
+
39
+ def spacing(jobs) = Setup::ProjectConfig.new(@project).job_spacing || (DAY / [jobs, 1].max)
40
+
41
+ # When each of the project's jobs still running or waiting to began, or is to.
42
+ def begun(all) = all.select { |one| BEGUN.include?(one.state) }.map { |one| Time.parse(one.run[:started_at]) }
43
+
44
+ def standings
21
45
  Pipeline::RunCanceller.new.record_dead_jobs(@db)
22
- Standings.new(@db, @project, now: @now).all.select(&:due_now?).map(&:job)
46
+ Standings.new(@db, @project, now: @now).all
23
47
  end
24
48
  end
25
49
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "job_run"
4
4
  require_relative "../persistence/database"
5
5
  require_relative "../pipeline/worktrees"
6
+ require_relative "../pipeline/priorities"
6
7
 
7
8
  module FunCi
8
9
  module Jobs
@@ -10,17 +11,24 @@ module FunCi
10
11
  # jobs). The trigger that forks it exits when its pipeline ends, and the
11
12
  # terminal of the commit may close; a job can run for hours, so it leads a
12
13
  # session of its own, prints nowhere, and opens its own connection, since
13
- # a child must not inherit one.
14
- module JobFork
15
- def self.start(job, commit, project:, db_path:)
16
- Process.detach(fork { run_in_child(job, commit, project, db_path) })
14
+ # a child must not inherit one. `priorities` says what its script runs under.
15
+ class JobFork
16
+ def initialize(project:, db_path:, priorities: Pipeline::Priorities.for(RUBY_PLATFORM))
17
+ @project = project
18
+ @db_path = db_path
19
+ @priorities = priorities
17
20
  end
18
21
 
19
- def self.run_in_child(job, commit, project, db_path)
22
+ # at: when the job's turn to start comes (Schedule); now when nil.
23
+ def start(job, commit, at: nil) = Process.detach(fork { run_in_child(job, commit, at) })
24
+
25
+ private
26
+
27
+ def run_in_child(job, commit, at)
20
28
  Process.setsid
21
29
  silence
22
- db = Persistence::Database.connection(db_path)
23
- JobRun.new(job, commit, site(project, db), Pipeline::Seams.new).start
30
+ db = Persistence::Database.connection(@db_path)
31
+ JobRun.new(job, commit, site(db), Pipeline::Seams.new(priorities: @priorities)).start(at: at)
24
32
  ensure
25
33
  db&.close
26
34
  end
@@ -28,18 +36,17 @@ module FunCi
28
36
  # The process's own descriptors, whatever $stdout and $stderr stand for
29
37
  # (a test's StringIO, a guard's file), onto an open /dev/null, since
30
38
  # reopening by path can't change a stream's access mode.
31
- def self.silence
39
+ def silence
32
40
  File.open(File::NULL, File::RDWR) do |null|
33
41
  (0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
34
42
  end
35
43
  end
36
44
 
37
- def self.site(project, db)
38
- worktrees = Pipeline::Worktrees.new(project)
39
- Site.new(project: project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
45
+ def site(db)
46
+ worktrees = Pipeline::Worktrees.new(@project)
47
+ Site.new(project: @project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
40
48
  clock: -> { Time.now })
41
49
  end
42
- private_class_method :run_in_child, :silence, :site
43
50
  end
44
51
  end
45
52
  end
@@ -14,8 +14,8 @@ module FunCi
14
14
  # One run of a job, in the process that runs it (design.md, Daily and
15
15
  # weekly jobs): with the job's lock held, a run left running is known to
16
16
  # be dead and is recorded failed, then the run is claimed if the job is
17
- # due, and the job runs on the commit in its own worktree, recorded as a
18
- # stage is.
17
+ # due, waits its turn if it has one (Schedule), and the job runs on the
18
+ # commit in its own worktree, recorded as a stage is.
19
19
  class JobRun
20
20
  def initialize(job, commit, site, seams)
21
21
  @job = job
@@ -25,9 +25,10 @@ module FunCi
25
25
  end
26
26
 
27
27
  # The run's id, or nil when another process runs the job or it isn't due.
28
- def start
28
+ # at: when its turn to start comes (Schedule); now when nil.
29
+ def start(at: nil)
29
30
  slot = @site.locks.take(@job.name)
30
- slot && holding(slot) { claimed(slot) }
31
+ slot && holding(slot) { claimed(slot, at || @site.clock.call) }
31
32
  end
32
33
 
33
34
  private
@@ -41,10 +42,27 @@ module FunCi
41
42
  slot.release
42
43
  end
43
44
 
44
- def claimed(slot)
45
+ def claimed(slot, at)
45
46
  runs.died(@job.name)
46
47
  id = runs.claim(@job, commit: @commit.to_h, lock_file: slot.lock_file, now: @site.clock.call)
47
- id && execute(slot, id)
48
+ id && (turn_came?(id, at) ? execute(slot, id) : id)
49
+ end
50
+
51
+ # Waits, scheduled, for the run's turn, the lock held; false when it was
52
+ # cancelled meanwhile.
53
+ def turn_came?(id, at)
54
+ seconds = at - @site.clock.call
55
+ return true unless seconds.positive?
56
+
57
+ scheduled(id, at)
58
+ @site.wait.call(seconds)
59
+ runs.start_turn(id, @site.clock.call).positive?
60
+ end
61
+
62
+ # This process is recorded, so a cancel stops it while it waits.
63
+ def scheduled(id, at)
64
+ recorder.started_by(id, Process.pid, budget: budget)
65
+ runs.wait_until(id, at)
48
66
  end
49
67
 
50
68
  def execute(slot, id)
@@ -64,7 +82,8 @@ module FunCi
64
82
 
65
83
  def execution(slot)
66
84
  Pipeline::StageExecution.new(seams: seams, dir: slot.path, commit: @commit,
67
- launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock] })
85
+ launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock],
86
+ priority: seams.priorities.job })
68
87
  end
69
88
 
70
89
  # The commit's own copy of the script when it has one, as a pipeline's stages are.
@@ -6,7 +6,7 @@ require_relative "../pipeline/slot"
6
6
  module FunCi
7
7
  module Jobs
8
8
  # Each job's worktree and the lock beside it, under a project's
9
- # <git-common-dir>/fun-ci/jobs: `<name>` and `<name>.lock`. Whoever holds
9
+ # Worktrees#jobs_root: `<name>` and `<name>.lock`. Whoever holds
10
10
  # a job's lock runs it, so it runs once at a time, and a lock dies with
11
11
  # the process that held it.
12
12
  class Locks
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Jobs
5
+ # When each of the jobs a commit starts begins (design.md, Daily and
6
+ # weekly jobs): one at a time, `spacing` seconds apart, and no sooner
7
+ # than `spacing` after the latest start of the project's jobs already
8
+ # running or waiting to (`begun`), so their work is spread over time
9
+ # rather than taking every core at once. A spacing of 0 starts all now.
10
+ class Schedule
11
+ def initialize(spacing, begun, now:)
12
+ @spacing = spacing
13
+ @begun = begun
14
+ @now = now
15
+ end
16
+
17
+ # [job, its start] for each of `jobs`, in their order.
18
+ def starts(jobs) = jobs.each_with_index.map { |job, index| [job, first + (index * @spacing)] }
19
+
20
+ private
21
+
22
+ def first = @begun.empty? ? @now : [@now, @begun.max + @spacing].max
23
+ end
24
+ end
25
+ end
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "wall_clock_wait"
4
+
3
5
  module FunCi
4
6
  module Jobs
5
7
  # Where a project's jobs run and are recorded: the project's path, the
6
- # database, its Pipeline::Worktrees (#check_out), its job Locks, and
7
- # `clock`, which answers the time now.
8
- Site = Data.define(:project, :db, :worktrees, :locks, :clock)
8
+ # database, its Pipeline::Worktrees (#check_out), its job Locks,
9
+ # `clock`, which answers the time now, and `wait`, which waits the
10
+ # seconds it is given, for a run's turn to start (Schedule).
11
+ Site = Data.define(:project, :db, :worktrees, :locks, :clock, :wait)
12
+
13
+ class Site
14
+ def initialize(wait: WallClockWait.new, **given) = super
15
+ end
9
16
  end
10
17
  end