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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require_relative "folders"
4
5
  require_relative "due"
5
6
  require_relative "state"
@@ -19,6 +20,9 @@ module FunCi
19
20
  def needs_you? = State.needs_you?(state)
20
21
  def due_now? = due.now?
21
22
  def due_at = due.at
23
+
24
+ # When a run waiting its turn (Schedule) is to start; nil for any other.
25
+ def starts_at = state == "scheduled" ? Time.parse(run[:started_at]) : nil
22
26
  end
23
27
 
24
28
  # Where each of a project's jobs stands at `now`, read once for the
@@ -4,12 +4,13 @@ module FunCi
4
4
  module Jobs
5
5
  # A job's state, read once from its latest run as job_runs keeps it
6
6
  # (design.md, Daily and weekly jobs): `due` while it never ran, then
7
- # `running`, `passed`, `failed`, `over_budget`, `cancelled`, or `lost`
7
+ # `scheduled` while it waits its turn to start (Schedule), `running`,
8
+ # `passed`, `failed`, `over_budget`, `cancelled`, or `lost`
8
9
  # when its process died before it said how the run ended. Each surface
9
10
  # (the console, the agent commands) says these in its own words.
10
11
  module State
11
12
  OF_STATUS = { "completed" => "passed", "failed" => "failed", "timed_out" => "over_budget",
12
- "running" => "running", "cancelled" => "cancelled" }.freeze
13
+ "running" => "running", "scheduled" => "scheduled", "cancelled" => "cancelled" }.freeze
13
14
  NEEDS_YOU = %w[failed over_budget lost].freeze
14
15
 
15
16
  # run: { status:, completed_at: }, or nil when the job never ran.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Jobs
5
+ # Waits seconds by the wall clock, a minute at a time, for a job's turn
6
+ # (Schedule), which can be hours away: sleep's own count stops while the
7
+ # machine sleeps, and a laptop asleep for part of the wait would start the
8
+ # job late. It sleeps no more minutes than the wait holds and one, so a
9
+ # clock that never moves can't keep it waiting for ever.
10
+ class WallClockWait
11
+ MINUTE = 60
12
+
13
+ def initialize(clock: -> { Time.now }, sleep: Kernel.method(:sleep))
14
+ @clock = clock
15
+ @sleep = sleep
16
+ end
17
+
18
+ def call(seconds)
19
+ until_at = @clock.call + seconds
20
+ ((seconds / MINUTE).ceil + 1).times do
21
+ left = until_at - @clock.call
22
+ break unless left.positive?
23
+
24
+ @sleep.call([left, MINUTE].min)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -2,50 +2,47 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "active_runs"
5
- require "json"
6
- require_relative "../evidence/document"
5
+ require_relative "died"
7
6
 
8
7
  module FunCi
9
8
  module Persistence
10
9
  # Daily and weekly jobs' runs still running, of every project, as the
11
10
  # console sees them: to cancel one, and to notice one whose process died.
12
11
  module ActiveJobs
12
+ # A run is still going while it runs, or waits its turn to (Jobs::Schedule).
13
+ ACTIVE = "status IN ('running', 'scheduled')"
14
+
13
15
  # The job run with this id as an ActiveRun, when it still runs; none otherwise.
14
16
  def self.with_id(db, id)
15
17
  db.execute("SELECT id, commit_hash, pid, group_pid, lock_file FROM job_runs " \
16
- "WHERE id = ? AND status = 'running'", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
18
+ "WHERE id = ? AND #{ACTIVE}", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
17
19
  ActiveRun.new(id: run_id, commit_hash: sha, processes: [pid].compact, stage_groups: [group_pid].compact,
18
20
  slot_lock: lock_file)
19
21
  end
20
22
  end
21
23
 
22
24
  # [id, lock file] of each job run still running.
23
- def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE status = 'running'")
24
-
25
- # What a run whose process died keeps: that it has no result, and why
26
- # that may be. It keeps no end, since when it was found dead says
27
- # nothing of how long it ran.
28
- DIED_FACT = { name: "ended", extractor: "fun-ci",
29
- value: "its process stopped before it said how the run ended (the machine slept or " \
30
- "restarted, or something killed it), so this run has no result" }.freeze
31
- DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
25
+ def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE #{ACTIVE}")
32
26
 
33
- def self.cancelled(db, id) = ended(db, id, "cancelled")
27
+ def self.cancelled(db, id) = ended(db, id, "cancelled", ACTIVE)
34
28
 
35
- # A run whose process is gone without recording how it ended: failed, with no end.
29
+ # A run whose process is gone without recording how it ended: failed,
30
+ # with no end; one that was still waiting its turn never ran, so it is
31
+ # cancelled, and the job is due at the next commit.
36
32
  def self.died(db, id)
33
+ ended(db, id, "cancelled", "status = 'scheduled'")
37
34
  db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
38
- [JSON.generate(DIED.to_h), id])
35
+ [Died.evidence("run"), id])
39
36
  end
40
37
 
41
38
  # The ids of a project's job's runs still running.
42
39
  def self.running_of(db, project, name)
43
- db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND status = 'running'",
40
+ db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND #{ACTIVE}",
44
41
  [project, name]).flatten
45
42
  end
46
43
 
47
- def self.ended(db, id, status)
48
- db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
44
+ def self.ended(db, id, status, was)
45
+ db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND #{was}",
49
46
  [status, Time.now.utc.iso8601(3), id])
50
47
  end
51
48
  private_class_method :ended
@@ -3,6 +3,7 @@
3
3
  require "time"
4
4
  require_relative "pipeline_run"
5
5
  require_relative "stage_job"
6
+ require_relative "died"
6
7
 
7
8
  module FunCi
8
9
  module Persistence
@@ -39,10 +40,11 @@ module FunCi
39
40
  "AND pipeline_runs.pid IS NOT NULL")
40
41
  end
41
42
 
42
- # Records the slow stage failed, unless its result was recorded meanwhile.
43
+ # Records the slow stage failed, and that its process died, unless its
44
+ # result was recorded meanwhile.
43
45
  def self.slow_suite_died(db, job_id)
44
- db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ? WHERE id = ? AND status = 'running'",
45
- [Time.now.utc.iso8601(3), job_id])
46
+ db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ?, evidence = ? " \
47
+ "WHERE id = ? AND status = 'running'", [Time.now.utc.iso8601(3), Died.evidence("stage"), job_id])
46
48
  end
47
49
 
48
50
  def self.cancelled(db, run)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../evidence/document"
5
+
6
+ module FunCi
7
+ module Persistence
8
+ # The evidence kept of a stage or job run whose process died: that it has
9
+ # no result, and why that may be, so it reads apart from a failure of the
10
+ # code. It keeps no end, since when it was found dead says nothing of how
11
+ # long it ran.
12
+ module Died
13
+ def self.evidence(what)
14
+ fact = { name: "ended", extractor: "fun-ci",
15
+ value: "its process stopped before it said how the #{what} ended (the machine slept or " \
16
+ "restarted, or something killed it), so this #{what} has no result" }
17
+ JSON.generate(Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [fact]).to_h)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -35,6 +35,7 @@ module FunCi
35
35
 
36
36
  # No stage shares a job's worktree.
37
37
  def alongside(_id) = []
38
+ def alongside_elsewhere(_id) = []
38
39
 
39
40
  private
40
41
 
@@ -33,6 +33,13 @@ module FunCi
33
33
  end
34
34
  end
35
35
 
36
+ # Records the claimed run `id` waiting its turn to start at `at` (Jobs::Schedule).
37
+ def wait_until(id, at) = update(id, "scheduled", at, "status = 'running'")
38
+
39
+ # Records the run `id` running from `now`, its turn come, and answers how
40
+ # many runs that was: none when it was cancelled while it waited.
41
+ def start_turn(id, now) = update(id, "running", now, "status = 'scheduled'")
42
+
36
43
  # The project's newest job runs, newest first.
37
44
  def recent(limit:) = query("WHERE project_path = ? ORDER BY id DESC LIMIT ?", @project, limit)
38
45
 
@@ -51,6 +58,11 @@ module FunCi
51
58
  @db.last_insert_row_id
52
59
  end
53
60
 
61
+ def update(id, status, at, was)
62
+ @db.execute("UPDATE job_runs SET status = ?, started_at = ? WHERE id = ? AND #{was}", [status, stamp(at), id])
63
+ @db.changes
64
+ end
65
+
54
66
  def forget_beyond(name)
55
67
  ids = @db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? ORDER BY id DESC " \
56
68
  "LIMIT -1 OFFSET ?", [@project, name, KEPT]).flatten
@@ -19,6 +19,7 @@ module FunCi
19
19
  def trunk_heads(**) = {}
20
20
  def trunk_fetches = TrunkRecording::NoFetches.new
21
21
  def alongside(_job_id) = []
22
+ def alongside_elsewhere(_job_id) = []
22
23
  def stage_process(_job_id, _pid); end
23
24
  def slot_taken(_lock_file); end
24
25
  def foreground_done; end
@@ -97,6 +97,7 @@ module FunCi
97
97
 
98
98
  # The stages that shared the slot with this one (architecture.md, "Evidence of a failed stage").
99
99
  def alongside(job_id) = tolerating { StageJob.alongside(@db, job_id) } || []
100
+ def alongside_elsewhere(job_id) = tolerating { StageJob.alongside_elsewhere(@db, job_id) } || []
100
101
 
101
102
  def keep_exit(job_id, exit_status, signal)
102
103
  tolerating { StageJob.keep_exit(@db, job_id, exit_status, signal) }
@@ -5,7 +5,8 @@ require "time"
5
5
  module FunCi
6
6
  module Persistence
7
7
  module PipelineRun
8
- COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at trunk_started_at].freeze
8
+ COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at trunk_started_at
9
+ trigger_pid].freeze
9
10
  SELECT = "SELECT #{COLUMNS.join(", ")} FROM pipeline_runs".freeze
10
11
 
11
12
  def self.create(db, commit_hash:, branch:, project_path: nil)
@@ -55,9 +56,8 @@ module FunCi
55
56
  db.execute("UPDATE pipeline_runs SET trunk_started_at = ? WHERE id = ?", [at.utc.iso8601, id])
56
57
  end
57
58
 
58
- def self.update_status(db, id, new_status)
59
- now = Time.now.utc.iso8601
60
- db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
59
+ def self.update_status(db, id, new_status, at: Time.now)
60
+ db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, at.utc.iso8601, id])
61
61
  end
62
62
 
63
63
  def self.recent(db, limit: 20)
@@ -68,11 +68,15 @@ module FunCi
68
68
  NEEDING = "CASE status WHEN 'failed' THEN 0 WHEN 'timed_out' THEN 1 WHEN 'running' THEN 2 " \
69
69
  "WHEN 'scheduled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END"
70
70
 
71
- # The newest run of each of `limit` branches: those that most need you,
72
- # then those run most recently, in that order.
73
- def self.branch_heads(db, limit:)
74
- query(db, "WHERE id IN (SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch) " \
75
- "ORDER BY #{NEEDING}, id DESC LIMIT ?", limit)
71
+ # Each branch's newest run.
72
+ HEADS = "SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch"
73
+
74
+ # The newest run of each of `limit` branches, of the `among` whose newest
75
+ # run changed most recently: those that most need you, then those run
76
+ # most recently, in that order.
77
+ def self.branch_heads(db, limit:, among:)
78
+ query(db, "WHERE id IN (SELECT id FROM pipeline_runs WHERE id IN (#{HEADS}) " \
79
+ "ORDER BY updated_at DESC, id DESC LIMIT ?) ORDER BY #{NEEDING}, id DESC LIMIT ?", among, limit)
76
80
  end
77
81
 
78
82
  # A project's branch's runs, newest first.
@@ -51,6 +51,18 @@ module FunCi
51
51
  [id]).flatten
52
52
  end
53
53
 
54
+ # Stages of the project's other runs running at any moment between this
55
+ # stage's start and now, as "<short commit>'s <stage>".
56
+ def self.alongside_elsewhere(db, id)
57
+ db.execute("SELECT runs.commit_hash, others.stage FROM stage_jobs AS others " \
58
+ "JOIN pipeline_runs AS runs ON runs.id = others.pipeline_run_id " \
59
+ "JOIN stage_jobs AS me ON me.id = ? JOIN pipeline_runs AS mine ON mine.id = me.pipeline_run_id " \
60
+ "WHERE runs.project_path IS mine.project_path AND runs.id != mine.id " \
61
+ "AND others.started_at IS NOT NULL " \
62
+ "AND (others.completed_at IS NULL OR others.completed_at > me.started_at) ORDER BY others.id",
63
+ [id]).map { |sha, stage| "#{sha[0, 7]}'s #{stage}" }
64
+ end
65
+
54
66
  def self.ids(db) = db.execute("SELECT id FROM stage_jobs").flatten
55
67
 
56
68
  def self.find(db, id)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # Each stage's time budget in seconds (design.md); a worktree just made
6
+ # gives lint and build the slow suite's, once (SlotRun).
7
+ DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # Kills a process group and what left it (ProcessTree): TERM, then KILL.
6
+ # A member forking as the KILL arrives leaves a child in the group that
7
+ # the signal missed, so the group is killed again while any member lives,
8
+ # a bounded number of times, since a mutant may stop it ever going.
9
+ module GroupKill
10
+ ROUNDS = 10
11
+
12
+ # escaped: further targets, -pgid for a group, pid for a process.
13
+ def self.kill(pid, escaped, signal: Process.method(:kill))
14
+ %w[TERM KILL].product([-pid, *escaped]).each { |name, target| send_to(signal, name, target) }
15
+ ROUNDS.times do
16
+ return unless lives?(pid, signal)
17
+
18
+ send_to(signal, "KILL", -pid)
19
+ end
20
+ end
21
+
22
+ # On macOS a group left with only zombies answers EPERM, not ESRCH.
23
+ def self.lives?(pid, signal)
24
+ signal.call(0, -pid)
25
+ true
26
+ rescue Errno::ESRCH, Errno::EPERM
27
+ false
28
+ end
29
+
30
+ def self.send_to(signal, name, target)
31
+ signal.call(name, target)
32
+ rescue Errno::ESRCH, Errno::EPERM
33
+ # Gone already.
34
+ end
35
+ private_class_method :lives?, :send_to
36
+ end
37
+ end
38
+ end
@@ -25,13 +25,15 @@ module FunCi
25
25
  # fork. The project's due jobs start beside the run, each in a process
26
26
  # of its own (design.md, Daily and weekly jobs), once the run has
27
27
  # started, and trouble starting them never stops it. jobs: what starts
28
- # them, given the commit and the database's path.
28
+ # them, given the commit (by its full SHA, as the run keeps it) and the
29
+ # database's path.
29
30
  def self.fork_pipeline(commit_hash:, branch:, db_path:, jobs: method(:start_due_jobs))
30
31
  return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
31
32
 
32
33
  notice = first_fetch(db_path)
33
- Process.detach(fork { run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path) })
34
- Forked.new(notice: notice, jobs: trouble { jobs.call(Commit.new(sha: commit_hash, branch: branch), db_path) })
34
+ commit = Commit.new(sha: Seams.full_sha(commit_hash) || commit_hash, branch: branch)
35
+ Process.detach(fork { run_in_child(commit_hash: commit.sha, branch: branch, db_path: db_path) })
36
+ Forked.new(notice: notice, jobs: trouble { jobs.call(commit, db_path) })
35
37
  end
36
38
 
37
39
  # What went wrong starting the jobs, or nil.
@@ -50,21 +52,24 @@ module FunCi
50
52
  db&.close
51
53
  end
52
54
 
53
- # Which jobs are due is read before any fork, which must not inherit the connection.
54
- def self.start_due_jobs(commit, db_path)
55
- due_jobs(db_path).each { |job| Jobs::JobFork.start(job, commit, project: Dir.pwd, db_path: db_path) }
55
+ # Which jobs are due, and when each one's turn comes, is read before any
56
+ # fork, which must not inherit the connection. fork: what starts each.
57
+ def self.start_due_jobs(commit, db_path, fork: Jobs::JobFork.new(project: Dir.pwd, db_path: db_path))
58
+ due_jobs(db_path).each { |job, at| fork.start(job, commit, at: at) }
56
59
  end
57
60
 
58
61
  def self.due_jobs(db_path)
59
62
  db = Persistence::Database.connection(db_path)
60
- Jobs::DueJobs.new(Dir.pwd, db, now: Time.now).list
63
+ Jobs::DueJobs.new(Dir.pwd, db, now: Time.now).starts
61
64
  ensure
62
65
  db&.close
63
66
  end
64
67
 
65
68
  # Trigger requires this file, through TriggerCommand, so it is loaded
66
- # here, where it is used, rather than above.
69
+ # here, where it is used, rather than above. The run leads a session of
70
+ # its own, so that ending the shell that committed doesn't end it.
67
71
  def self.run_in_child(commit_hash:, branch:, db_path:)
72
+ Process.setsid
68
73
  require_relative "trigger"
69
74
  recorder = Persistence::DbRecorder.new(Persistence::Database.connection(db_path))
70
75
  trigger = trigger(commit_hash, branch, recorder)
@@ -78,7 +83,7 @@ module FunCi
78
83
  Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
79
84
  io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
80
85
  end
81
- private_class_method :trigger, :first_fetch, :start_due_jobs, :due_jobs, :trouble
86
+ private_class_method :trigger, :first_fetch, :due_jobs, :trouble
82
87
  end
83
88
  end
84
89
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # What a daily or weekly job and the slow suite are started under, so
6
+ # they give way to the stages a push waits for: each a prefix of the
7
+ # command a stage's shell execs, or "" for none. On macOS nice barely
8
+ # moves the scheduler, and a QoS clamp of utility lets a job have every
9
+ # idle core yet yield to the stages; the slow suite is left unclamped
10
+ # there, since clamped beside clamped jobs it crawls. Elsewhere nice does it.
11
+ Priorities = Data.define(:job, :slow)
12
+
13
+ class Priorities
14
+ MACOS = new(job: "taskpolicy -c utility ", slow: "")
15
+ OTHER = new(job: "nice -n 19 ", slow: "nice -n 10 ")
16
+ # fun-ci's own priority, for both.
17
+ NONE = new(job: "", slow: "")
18
+
19
+ # platform: as RUBY_PLATFORM names it.
20
+ def self.for(platform) = platform.include?("darwin") ? MACOS : OTHER
21
+ end
22
+ end
23
+ end
@@ -3,6 +3,9 @@
3
3
  require_relative "gate"
4
4
  require_relative "git_environment"
5
5
  require_relative "output_window"
6
+ require_relative "process_tree"
7
+ require_relative "group_kill"
8
+ require_relative "../evidence/process_table"
6
9
 
7
10
  module FunCi
8
11
  module Pipeline
@@ -24,13 +27,15 @@ module FunCi
24
27
 
25
28
  # Where the command runs, what it finds in its environment besides
26
29
  # fun-ci's own, the window what it prints is written to, what to do
27
- # with its pid when it runs over budget, before it is killed, and the
28
- # open files it holds as long as it runs, such as a lock.
29
- Launch = Data.define(:chdir, :env, :output, :before_kill, :held)
30
+ # with its pid when it runs over budget, before it is killed, the
31
+ # open files it holds as long as it runs, such as a lock, and what it
32
+ # is run under (a Priorities prefix, "" for fun-ci's own priority).
33
+ Launch = Data.define(:chdir, :env, :output, :before_kill, :held, :priority)
30
34
 
31
35
  class Launch
32
36
  def initialize(**given)
33
- super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, held: [], **given)
37
+ super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, held: [], priority: "",
38
+ **given)
34
39
  end
35
40
  end
36
41
 
@@ -77,7 +82,7 @@ module FunCi
77
82
  end
78
83
 
79
84
  def spawn_gated(cmd, writer, gate, launch)
80
- Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, cmd),
85
+ Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, "#{launch.priority}#{cmd}"),
81
86
  out: writer, err: writer, 9 => gate.child_end, pgroup: true, chdir: launch.chdir, **held(launch))
82
87
  end
83
88
 
@@ -110,8 +115,10 @@ module FunCi
110
115
  [output, status, false]
111
116
  end
112
117
 
118
+ # With the group go what it started that left it (ProcessTree), found
119
+ # before the kill orphans them (GroupKill).
113
120
  def kill_process_group(pid)
114
- %w[TERM KILL].each { |signal| ignoring_errors { Process.kill(signal, -pid) } }
121
+ GroupKill.kill(pid, ProcessTree.escaped([pid], Evidence::ProcessTable.now))
115
122
  ignoring_errors { Process.waitpid(pid) }
116
123
  end
117
124
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # What to kill besides a stage's process groups: each process descended
6
+ # from one of their members, by parent id, that has left for a group or
7
+ # session of its own, as Node's detached children do. The rows must be
8
+ # read before the kill, since a killed parent's children move to init.
9
+ # A double-forked daemon has moved to init already and is not reached.
10
+ module ProcessTree
11
+ # Signal targets: -pid for an escapee leading its own group, which
12
+ # takes that group with it, and the pid alone of one in any other group.
13
+ def self.escaped(groups, rows)
14
+ escapees = outside(groups, descendants(in_groups(groups, rows), rows))
15
+ leaders = escapees.select { |row| row.pgid == row.pid }.map(&:pid)
16
+ leaders.map(&:-@) + escapees.reject { |row| leaders.include?(row.pgid) }.map(&:pid)
17
+ end
18
+
19
+ def self.in_groups(groups, rows) = rows.select { |row| groups.include?(row.pgid) }
20
+ def self.outside(groups, rows) = rows.reject { |row| groups.include?(row.pgid) }
21
+
22
+ # Bounded by the table's size, so a table whose parent ids form a cycle still ends.
23
+ def self.descendants(found, rows)
24
+ rows.size.times do
25
+ more = children(found.map(&:pid), rows) - found
26
+ return found if more.empty?
27
+
28
+ found += more
29
+ end
30
+ found
31
+ end
32
+
33
+ def self.children(parents, rows) = rows.select { |row| parents.include?(row.ppid) }
34
+ private_class_method :in_groups, :outside, :descendants, :children
35
+ end
36
+ end
37
+ end
@@ -17,6 +17,11 @@ module FunCi
17
17
  @stdout.puts "fun-ci: fast #{result_marker(passed)}"
18
18
  end
19
19
 
20
+ def cold_slot(seconds)
21
+ @stdout.puts "fun-ci: a cold worktree (new, just cleaned, or unused for a day), so lint and build have " \
22
+ "#{seconds}s this once."
23
+ end
24
+
20
25
  def slow_launched
21
26
  @stdout.puts "fun-ci: slow (running in background)"
22
27
  end
@@ -5,18 +5,24 @@ require_relative "../persistence/active_jobs"
5
5
  require_relative "../persistence/run_status"
6
6
  require_relative "../persistence/write_trouble"
7
7
  require_relative "slot_lock"
8
+ require_relative "process_tree"
9
+ require_relative "../evidence/process_table"
8
10
 
9
11
  module FunCi
10
12
  module Pipeline
11
13
  # Stops every process of a run. The run's own processes go first, so none
12
14
  # of them records the run as failed once its stages die; then each stage's
13
- # process group. A run whose slot nobody holds any more has died already,
15
+ # process group, and last what a stage started that left its group
16
+ # (ProcessTree). A run whose slot nobody holds any more has died already,
14
17
  # and the pids it recorded may belong to other processes now, so it is
15
18
  # left alone; so is this process.
16
19
  class RunCanceller
17
- def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?))
20
+ # process_table: what ps lists now, as Evidence::ProcessTable rows.
21
+ def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?),
22
+ process_table: Evidence::ProcessTable.method(:now))
18
23
  @killer = killer
19
24
  @slot_held = slot_held
25
+ @process_table = process_table
20
26
  end
21
27
 
22
28
  # Stops the run and records it, and its unfinished stages, cancelled.
@@ -60,11 +66,17 @@ module FunCi
60
66
  def stop(run)
61
67
  return unless alive?(run)
62
68
 
63
- (run.processes - [Process.pid] + run.stage_groups.map(&:-@)).each { |pid| kill(pid) }
69
+ (run.processes - [Process.pid] + run.stage_groups.map(&:-@) + escaped(run)).each { |pid| kill(pid) }
64
70
  end
65
71
 
66
72
  private
67
73
 
74
+ def escaped(run)
75
+ return [] if run.stage_groups.empty?
76
+
77
+ ProcessTree.escaped(run.stage_groups, @process_table.call)
78
+ end
79
+
68
80
  # A stage its process started after `run` was read. A run's process
69
81
  # records a stage's group before it lets the stage start, so once those
70
82
  # processes are dead, looking again finds every stage they started.
@@ -25,6 +25,17 @@ module FunCi
25
25
  @holders -= 1
26
26
  @lock.close if @holders.zero? && !@lock.closed?
27
27
  end
28
+
29
+ # Whether its worktree was just made, so has no caches yet.
30
+ def cold? = false
31
+
32
+ # The same slot, before anyone shares it, in a worktree just made.
33
+ def as_cold = ColdSlot.new(path, lock, lock_file)
34
+ end
35
+
36
+ # A slot whose worktree was just made: its build starts from nothing.
37
+ class ColdSlot < Slot
38
+ def cold? = true
28
39
  end
29
40
  end
30
41
  end
@@ -34,22 +34,36 @@ module FunCi
34
34
 
35
35
  def recorder = @seams.recorder
36
36
  def progress = ProgressReporter.new(stdout: @io.stdout)
37
- def stage_runner = StageRunner.new(commit: @commit, stdout: @io.stdout, seams: @seams, dir: @slot.path)
37
+
38
+ def stage_runner(seams = @seams)
39
+ StageRunner.new(commit: @commit, stdout: @io.stdout, seams: seams, dir: @slot.path)
40
+ end
38
41
 
39
42
  # This process's part is over; the slow suite may still be running.
40
43
  def released(exit_code)
41
44
  @slot.release
42
- recorder.foreground_done
43
45
  exit_code
44
46
  end
45
47
 
46
48
  def phase_one_passed?(config)
47
- results = %w[lint build].to_h { |stage| [stage, Thread.new { stage_runner.passes?(config, stage) }] }
49
+ runner = stage_runner(phase_one_seams)
50
+ results = %w[lint build].to_h { |stage| [stage, Thread.new { runner.passes?(config, stage) }] }
48
51
  .transform_values(&:value)
49
52
  progress.phase_one_result(results)
50
53
  results.values.all?
51
54
  end
52
55
 
56
+ # A worktree just made has no caches, so its lint and build are no
57
+ # measure of the project's: they get the slow suite's budget, this once.
58
+ def phase_one_seams
59
+ return @seams unless @slot.cold?
60
+
61
+ cold = @seams.budgets["slow"]
62
+ progress.cold_slot(cold)
63
+ longer = %w[lint build].to_h { |stage| [stage, [@seams.budgets[stage], cold].max] }
64
+ @seams.with(time_budgets: @seams.time_budgets.merge(longer))
65
+ end
66
+
53
67
  def fast_passed?(config)
54
68
  passed = stage_runner.passes?(config, "fast")
55
69
  progress.fast_result(passed)
@@ -65,7 +79,8 @@ module FunCi
65
79
  # Called with the recorder and job to record the slow suite with, in
66
80
  # whichever process runs it; the slot is let go once it is recorded.
67
81
  def slow_suite(config, slot)
68
- execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit)
82
+ execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit,
83
+ launching: { priority: @seams.priorities.slow })
69
84
  command = config.stage_command("slow", @commit.sha)
70
85
  ->(recorder, job_id) { holding(slot) { execution.run("slow", command, recorder, job_id) } }
71
86
  end