fun_ci 2.2.0 → 2.4.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 +141 -0
- data/README.md +19 -11
- 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 +3 -1
- data/lib/fun_ci/cli_command_help.rb +51 -0
- data/lib/fun_ci/cli_help.rb +11 -10
- data/lib/fun_ci/console/board_data.rb +7 -2
- 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/console/trunk_marks.rb +3 -1
- data/lib/fun_ci/evidence/about_stage.rb +1 -1
- data/lib/fun_ci/evidence/collector.rb +6 -3
- data/lib/fun_ci/evidence/outcome.rb +6 -3
- 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/locks.rb +1 -1
- 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 +15 -18
- data/lib/fun_ci/persistence/active_runs.rb +5 -3
- data/lib/fun_ci/persistence/died.rb +21 -0
- data/lib/fun_ci/persistence/job_recorder.rb +1 -0
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/null_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +13 -9
- data/lib/fun_ci/persistence/stage_job.rb +12 -0
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/group_kill.rb +38 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +14 -9
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +13 -6
- data/lib/fun_ci/pipeline/process_tree.rb +37 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +5 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/stage_end.rb +2 -1
- 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/workspaces.rb +2 -1
- data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
- data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
- data/lib/fun_ci/pipeline/worktrees.rb +39 -6
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +19 -8
- data/lib/fun_ci/setup/eslint_detection.rb +38 -0
- 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/init_options.rb +34 -0
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +44 -9
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/placeholders.rb +30 -0
- data/lib/fun_ci/setup/project_config.rb +8 -1
- data/lib/fun_ci/setup/settings.rb +59 -10
- data/lib/fun_ci/setup/setup_checker.rb +16 -3
- data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
- data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
- data/lib/fun_ci/setup/stage_templates.rb +6 -4
- data/lib/fun_ci/setup/template_writer.rb +3 -5
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- 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
|
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
|
|
@@ -2,50 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "active_runs"
|
|
5
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
[
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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,21 +52,24 @@ 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
|
|
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, :
|
|
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,
|
|
28
|
-
# open files it holds as long as it runs, such as a lock
|
|
29
|
-
|
|
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: [],
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
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
|
|
@@ -34,22 +34,36 @@ module FunCi
|
|
|
34
34
|
|
|
35
35
|
def recorder = @seams.recorder
|
|
36
36
|
def progress = ProgressReporter.new(stdout: @io.stdout)
|
|
37
|
-
|
|
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
|
-
|
|
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
|