fun_ci 2.2.0 → 2.3.0

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +15 -8
  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 +1 -1
  19. data/lib/fun_ci/cli_help.rb +2 -1
  20. data/lib/fun_ci/console/job_message.rb +12 -6
  21. data/lib/fun_ci/console/job_order.rb +5 -4
  22. data/lib/fun_ci/console/key_handler.rb +3 -1
  23. data/lib/fun_ci/evidence/settings.rb +1 -1
  24. data/lib/fun_ci/jobs/due.rb +4 -3
  25. data/lib/fun_ci/jobs/due_jobs.rb +26 -2
  26. data/lib/fun_ci/jobs/job_fork.rb +19 -12
  27. data/lib/fun_ci/jobs/job_run.rb +26 -7
  28. data/lib/fun_ci/jobs/schedule.rb +25 -0
  29. data/lib/fun_ci/jobs/site.rb +10 -3
  30. data/lib/fun_ci/jobs/standings.rb +4 -0
  31. data/lib/fun_ci/jobs/state.rb +3 -2
  32. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  33. data/lib/fun_ci/persistence/active_jobs.rb +13 -7
  34. data/lib/fun_ci/persistence/job_runs.rb +12 -0
  35. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  36. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  37. data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
  38. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  39. data/lib/fun_ci/pipeline/process_runner.rb +7 -5
  40. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  41. data/lib/fun_ci/pipeline/slot.rb +11 -0
  42. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  43. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  44. data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
  45. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  46. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  47. data/lib/fun_ci/pipeline/worktrees.rb +3 -1
  48. data/lib/fun_ci/setup/agent_instructions.rb +21 -5
  49. data/lib/fun_ci/setup/commands.rb +3 -1
  50. data/lib/fun_ci/setup/hook_script.rb +9 -4
  51. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  52. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  53. data/lib/fun_ci/setup/installer.rb +3 -2
  54. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  55. data/lib/fun_ci/setup/project_config.rb +7 -1
  56. data/lib/fun_ci/setup/settings.rb +40 -11
  57. data/lib/fun_ci/setup/setup_checker.rb +14 -3
  58. data/lib/fun_ci/trunk/shown.rb +7 -4
  59. data/lib/fun_ci/version.rb +1 -1
  60. metadata +9 -1
@@ -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
 
@@ -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.
@@ -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
@@ -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
@@ -10,17 +10,20 @@ module FunCi
10
10
  # Daily and weekly jobs' runs still running, of every project, as the
11
11
  # console sees them: to cancel one, and to notice one whose process died.
12
12
  module ActiveJobs
13
+ # A run is still going while it runs, or waits its turn to (Jobs::Schedule).
14
+ ACTIVE = "status IN ('running', 'scheduled')"
15
+
13
16
  # The job run with this id as an ActiveRun, when it still runs; none otherwise.
14
17
  def self.with_id(db, id)
15
18
  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)|
19
+ "WHERE id = ? AND #{ACTIVE}", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
17
20
  ActiveRun.new(id: run_id, commit_hash: sha, processes: [pid].compact, stage_groups: [group_pid].compact,
18
21
  slot_lock: lock_file)
19
22
  end
20
23
  end
21
24
 
22
25
  # [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'")
26
+ def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE #{ACTIVE}")
24
27
 
25
28
  # What a run whose process died keeps: that it has no result, and why
26
29
  # that may be. It keeps no end, since when it was found dead says
@@ -30,22 +33,25 @@ module FunCi
30
33
  "restarted, or something killed it), so this run has no result" }.freeze
31
34
  DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
32
35
 
33
- def self.cancelled(db, id) = ended(db, id, "cancelled")
36
+ def self.cancelled(db, id) = ended(db, id, "cancelled", ACTIVE)
34
37
 
35
- # A run whose process is gone without recording how it ended: failed, with no end.
38
+ # A run whose process is gone without recording how it ended: failed,
39
+ # with no end; one that was still waiting its turn never ran, so it is
40
+ # cancelled, and the job is due at the next commit.
36
41
  def self.died(db, id)
42
+ ended(db, id, "cancelled", "status = 'scheduled'")
37
43
  db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
38
44
  [JSON.generate(DIED.to_h), id])
39
45
  end
40
46
 
41
47
  # The ids of a project's job's runs still running.
42
48
  def self.running_of(db, project, name)
43
- db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND status = 'running'",
49
+ db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND #{ACTIVE}",
44
50
  [project, name]).flatten
45
51
  end
46
52
 
47
- def self.ended(db, id, status)
48
- db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
53
+ def self.ended(db, id, status, was)
54
+ db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND #{was}",
49
55
  [status, Time.now.utc.iso8601(3), id])
50
56
  end
51
57
  private_class_method :ended
@@ -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
@@ -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)
@@ -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
@@ -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,14 +52,15 @@ 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
@@ -78,7 +81,7 @@ module FunCi
78
81
  Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
79
82
  io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
80
83
  end
81
- private_class_method :trigger, :first_fetch, :start_due_jobs, :due_jobs, :trouble
84
+ private_class_method :trigger, :first_fetch, :due_jobs, :trouble
82
85
  end
83
86
  end
84
87
  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
@@ -24,13 +24,15 @@ module FunCi
24
24
 
25
25
  # Where the command runs, what it finds in its environment besides
26
26
  # 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)
27
+ # with its pid when it runs over budget, before it is killed, the
28
+ # open files it holds as long as it runs, such as a lock, and what it
29
+ # is run under (a Priorities prefix, "" for fun-ci's own priority).
30
+ Launch = Data.define(:chdir, :env, :output, :before_kill, :held, :priority)
30
31
 
31
32
  class Launch
32
33
  def initialize(**given)
33
- super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, held: [], **given)
34
+ super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, held: [], priority: "",
35
+ **given)
34
36
  end
35
37
  end
36
38
 
@@ -77,7 +79,7 @@ module FunCi
77
79
  end
78
80
 
79
81
  def spawn_gated(cmd, writer, gate, launch)
80
- Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, cmd),
82
+ Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, "#{launch.priority}#{cmd}"),
81
83
  out: writer, err: writer, 9 => gate.child_end, pgroup: true, chdir: launch.chdir, **held(launch))
82
84
  end
83
85
 
@@ -17,6 +17,10 @@ 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 new worktree, whose caches are empty, so lint and build have #{seconds}s this once."
22
+ end
23
+
20
24
  def slow_launched
21
25
  @stdout.puts "fun-ci: slow (running in background)"
22
26
  end
@@ -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