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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +15 -8
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/commands.rb +3 -1
- data/lib/fun_ci/agent/job_events.rb +26 -7
- data/lib/fun_ci/agent/job_json.rb +10 -1
- data/lib/fun_ci/agent/job_report.rb +10 -6
- data/lib/fun_ci/agent/job_why_text.rb +11 -3
- data/lib/fun_ci/agent/jobs_text.rb +8 -3
- data/lib/fun_ci/agent/live_pipeline.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +5 -1
- data/lib/fun_ci/agent/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +15 -3
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +2 -1
- data/lib/fun_ci/console/job_message.rb +12 -6
- data/lib/fun_ci/console/job_order.rb +5 -4
- data/lib/fun_ci/console/key_handler.rb +3 -1
- data/lib/fun_ci/evidence/settings.rb +1 -1
- data/lib/fun_ci/jobs/due.rb +4 -3
- data/lib/fun_ci/jobs/due_jobs.rb +26 -2
- data/lib/fun_ci/jobs/job_fork.rb +19 -12
- data/lib/fun_ci/jobs/job_run.rb +26 -7
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +10 -3
- data/lib/fun_ci/jobs/standings.rb +4 -0
- data/lib/fun_ci/jobs/state.rb +3 -2
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +13 -7
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +7 -5
- data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
- data/lib/fun_ci/pipeline/worktrees.rb +3 -1
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +3 -1
- data/lib/fun_ci/setup/hook_script.rb +9 -4
- data/lib/fun_ci/setup/hook_writer.rb +13 -5
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +3 -2
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/project_config.rb +7 -1
- data/lib/fun_ci/setup/settings.rb +40 -11
- data/lib/fun_ci/setup/setup_checker.rb +14 -3
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- 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
|
-
|
|
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],
|
|
26
|
-
|
|
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,
|
|
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, "
|
|
11
|
-
"
|
|
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 =
|
|
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
|
|
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
|
data/lib/fun_ci/jobs/due.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/fun_ci/jobs/due_jobs.rb
CHANGED
|
@@ -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
|
|
46
|
+
Standings.new(@db, @project, now: @now).all
|
|
23
47
|
end
|
|
24
48
|
end
|
|
25
49
|
end
|
data/lib/fun_ci/jobs/job_fork.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
def
|
|
16
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
data/lib/fun_ci/jobs/job_run.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
data/lib/fun_ci/jobs/site.rb
CHANGED
|
@@ -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,
|
|
7
|
-
# `clock`, which answers the time now
|
|
8
|
-
|
|
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
|
data/lib/fun_ci/jobs/state.rb
CHANGED
|
@@ -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
|
-
# `
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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).
|
|
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, :
|
|
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,
|
|
28
|
-
# open files it holds as long as it runs, such as a lock
|
|
29
|
-
|
|
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: [],
|
|
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
|
data/lib/fun_ci/pipeline/slot.rb
CHANGED
|
@@ -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
|