fun_ci 2.1.1-aarch64-linux → 2.3.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +36 -6
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/command_support.rb +7 -0
  6. data/lib/fun_ci/agent/commands.rb +4 -1
  7. data/lib/fun_ci/agent/due_in.rb +21 -0
  8. data/lib/fun_ci/agent/events_command.rb +14 -7
  9. data/lib/fun_ci/agent/job_events.rb +56 -0
  10. data/lib/fun_ci/agent/job_json.rb +25 -0
  11. data/lib/fun_ci/agent/job_report.rb +89 -0
  12. data/lib/fun_ci/agent/job_why.rb +59 -0
  13. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  14. data/lib/fun_ci/agent/job_why_text.rb +59 -0
  15. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  16. data/lib/fun_ci/agent/jobs_text.rb +55 -0
  17. data/lib/fun_ci/agent/live_pipeline.rb +14 -6
  18. data/lib/fun_ci/agent/options.rb +3 -3
  19. data/lib/fun_ci/agent/output.rb +14 -2
  20. data/lib/fun_ci/agent/snapshots.rb +20 -1
  21. data/lib/fun_ci/agent/span.rb +24 -0
  22. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  23. data/lib/fun_ci/agent/starts_in.rb +14 -0
  24. data/lib/fun_ci/agent/status_command.rb +3 -2
  25. data/lib/fun_ci/agent/status_json.rb +1 -1
  26. data/lib/fun_ci/agent/status_text.rb +32 -6
  27. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  28. data/lib/fun_ci/agent/wait_command.rb +2 -1
  29. data/lib/fun_ci/agent/why_command.rb +6 -2
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +4 -1
  32. data/lib/fun_ci/console/board_data.rb +26 -13
  33. data/lib/fun_ci/console/console_state.rb +12 -6
  34. data/lib/fun_ci/console/job_message.rb +39 -0
  35. data/lib/fun_ci/console/job_order.rb +23 -0
  36. data/lib/fun_ci/console/job_rows.rb +21 -0
  37. data/lib/fun_ci/console/key_handler.rb +35 -14
  38. data/lib/fun_ci/console/paging.rb +26 -0
  39. data/lib/fun_ci/console/view.rb +18 -9
  40. data/lib/fun_ci/evidence/settings.rb +15 -8
  41. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  42. data/lib/fun_ci/jobs/due.rb +34 -0
  43. data/lib/fun_ci/jobs/due_jobs.rb +50 -0
  44. data/lib/fun_ci/jobs/folders.rb +50 -0
  45. data/lib/fun_ci/jobs/job.rb +31 -0
  46. data/lib/fun_ci/jobs/job_fork.rb +52 -0
  47. data/lib/fun_ci/jobs/job_run.rb +104 -0
  48. data/lib/fun_ci/jobs/locks.rb +30 -0
  49. data/lib/fun_ci/jobs/schedule.rb +25 -0
  50. data/lib/fun_ci/jobs/site.rb +17 -0
  51. data/lib/fun_ci/jobs/standings.rb +50 -0
  52. data/lib/fun_ci/jobs/state.rb +27 -0
  53. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  54. data/lib/fun_ci/persistence/active_jobs.rb +60 -0
  55. data/lib/fun_ci/persistence/database.rb +14 -4
  56. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  57. data/lib/fun_ci/persistence/job_runs.rb +80 -0
  58. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  59. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  60. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  61. data/lib/fun_ci/pipeline/pipeline_forker.rb +42 -10
  62. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  63. data/lib/fun_ci/pipeline/process_runner.rb +18 -6
  64. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  65. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  66. data/lib/fun_ci/pipeline/slot.rb +12 -1
  67. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  68. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  69. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  70. data/lib/fun_ci/pipeline/trigger_command.rb +12 -2
  71. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  72. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  73. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  74. data/lib/fun_ci/pipeline/worktrees.rb +11 -5
  75. data/lib/fun_ci/setup/agent_instructions.rb +24 -5
  76. data/lib/fun_ci/setup/commands.rb +3 -1
  77. data/lib/fun_ci/setup/hook_script.rb +9 -4
  78. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  79. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  80. data/lib/fun_ci/setup/installer.rb +3 -2
  81. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  82. data/lib/fun_ci/setup/project_config.rb +13 -1
  83. data/lib/fun_ci/setup/settings.rb +40 -11
  84. data/lib/fun_ci/setup/setup_checker.rb +20 -3
  85. data/lib/fun_ci/trunk/shown.rb +7 -4
  86. data/lib/fun_ci/version.rb +1 -1
  87. data/libexec/fun-ci-renderer +0 -0
  88. metadata +36 -1
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "folders"
4
+ require_relative "locks"
5
+ require_relative "site"
6
+ require_relative "../persistence/job_runs"
7
+ require_relative "../persistence/job_recorder"
8
+ require_relative "../pipeline/stage_execution"
9
+ require_relative "../pipeline/trigger_params"
10
+ require_relative "../evidence/document"
11
+
12
+ module FunCi
13
+ module Jobs
14
+ # One run of a job, in the process that runs it (design.md, Daily and
15
+ # weekly jobs): with the job's lock held, a run left running is known to
16
+ # be dead and is recorded failed, then the run is claimed if the job 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
+ class JobRun
20
+ def initialize(job, commit, site, seams)
21
+ @job = job
22
+ @commit = commit
23
+ @site = site
24
+ @seams = seams
25
+ end
26
+
27
+ # The run's id, or nil when another process runs the job or it isn't due.
28
+ # at: when its turn to start comes (Schedule); now when nil.
29
+ def start(at: nil)
30
+ slot = @site.locks.take(@job.name)
31
+ slot && holding(slot) { claimed(slot, at || @site.clock.call) }
32
+ end
33
+
34
+ private
35
+
36
+ def runs = Persistence::JobRuns.new(@site.db, @site.project)
37
+ def recorder = Persistence::JobRecorder.new(@site.db)
38
+
39
+ def holding(slot)
40
+ yield
41
+ ensure
42
+ slot.release
43
+ end
44
+
45
+ def claimed(slot, at)
46
+ runs.died(@job.name)
47
+ id = runs.claim(@job, commit: @commit.to_h, lock_file: slot.lock_file, now: @site.clock.call)
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)
66
+ end
67
+
68
+ def execute(slot, id)
69
+ recorder.started_by(id, Process.pid, budget: budget)
70
+ @site.worktrees.check_out(slot.path, @commit.sha)
71
+ execution(slot).run(@job.stage, command(slot.path), recorder, id)
72
+ id
73
+ rescue StandardError => e
74
+ failed(id, e.message)
75
+ end
76
+
77
+ # The script holds the job's lock too, so the job reads as alive while
78
+ # any of it runs, its runner gone or not.
79
+ # A day for every job, unless a test says otherwise.
80
+ def seams = @seams.with(time_budgets: { @job.stage => Job::BUDGET }.merge(@seams.time_budgets))
81
+ def budget = seams.budgets[@job.stage]
82
+
83
+ def execution(slot)
84
+ Pipeline::StageExecution.new(seams: seams, dir: slot.path, commit: @commit,
85
+ launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock],
86
+ priority: seams.priorities.job })
87
+ end
88
+
89
+ # The commit's own copy of the script when it has one, as a pipeline's stages are.
90
+ def command(dir)
91
+ committed = File.join(dir, ".fun-ci", @job.cadence, "#{@job.name}.sh")
92
+ (File.exist?(committed) ? @job.with(script: committed) : @job).command(@commit.sha)
93
+ end
94
+
95
+ # Whatever stopped the run, so it isn't left running: a run left running
96
+ # is only found dead once its lock is free, and then says nothing of why.
97
+ def failed(id, problem)
98
+ recorder.keep_evidence(id, Evidence::Document.broken(problem))
99
+ recorder.end_stage(id, "failed")
100
+ id
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../pipeline/slot"
5
+
6
+ module FunCi
7
+ module Jobs
8
+ # Each job's worktree and the lock beside it, under a project's
9
+ # <git-common-dir>/fun-ci/jobs: `<name>` and `<name>.lock`. Whoever holds
10
+ # a job's lock runs it, so it runs once at a time, and a lock dies with
11
+ # the process that held it.
12
+ class Locks
13
+ def initialize(root)
14
+ @root = root
15
+ end
16
+
17
+ # The job's worktree, held through its lock as a Pipeline::Slot, or nil
18
+ # while another process holds it.
19
+ def take(name)
20
+ FileUtils.mkdir_p(@root)
21
+ path = File.join(@root, name)
22
+ lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
23
+ return Pipeline::Slot.new(path, lock, lock.path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
24
+
25
+ lock.close
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
@@ -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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wall_clock_wait"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # Where a project's jobs run and are recorded: the project's path, the
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
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "folders"
5
+ require_relative "due"
6
+ require_relative "state"
7
+ require_relative "../persistence/job_runs"
8
+
9
+ module FunCi
10
+ module Jobs
11
+ # Where a job stands: its project, the job, its latest run as job_runs
12
+ # keeps it (nil while it never ran), and Due, which says whether the
13
+ # next commit starts it and when it is due again.
14
+ Standing = Data.define(:project, :job, :run, :due)
15
+
16
+ class Standing
17
+ def name = job.name
18
+ def cadence = job.cadence
19
+ def state = State.of(run)
20
+ def needs_you? = State.needs_you?(state)
21
+ def due_now? = due.now?
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
26
+ end
27
+
28
+ # Where each of a project's jobs stands at `now`, read once for the
29
+ # console, the agent commands and the trigger.
30
+ class Standings
31
+ def initialize(db, project, now:)
32
+ @db = db
33
+ @project = project
34
+ @now = now
35
+ end
36
+
37
+ # By name.
38
+ def all
39
+ runs = Persistence::JobRuns.new(@db, @project)
40
+ Folders.new(@project).jobs.map { |job| standing(job, runs.latest(job.name)) }
41
+ end
42
+
43
+ private
44
+
45
+ def standing(job, run)
46
+ Standing.new(project: @project, job: job, run: run, due: Due.new(run, job.period, now: @now))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Jobs
5
+ # A job's state, read once from its latest run as job_runs keeps it
6
+ # (design.md, Daily and weekly jobs): `due` while it never ran, then
7
+ # `scheduled` while it waits its turn to start (Schedule), `running`,
8
+ # `passed`, `failed`, `over_budget`, `cancelled`, or `lost`
9
+ # when its process died before it said how the run ended. Each surface
10
+ # (the console, the agent commands) says these in its own words.
11
+ module State
12
+ OF_STATUS = { "completed" => "passed", "failed" => "failed", "timed_out" => "over_budget",
13
+ "running" => "running", "scheduled" => "scheduled", "cancelled" => "cancelled" }.freeze
14
+ NEEDS_YOU = %w[failed over_budget lost].freeze
15
+
16
+ # run: { status:, completed_at: }, or nil when the job never ran.
17
+ def self.of(run)
18
+ return "due" unless run
19
+ return "lost" if run[:status] == "failed" && run[:completed_at].nil?
20
+
21
+ OF_STATUS.fetch(run[:status], "unknown")
22
+ end
23
+
24
+ def self.needs_you?(state) = NEEDS_YOU.include?(state)
25
+ end
26
+ end
27
+ end
@@ -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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "active_runs"
5
+ require "json"
6
+ require_relative "../evidence/document"
7
+
8
+ module FunCi
9
+ module Persistence
10
+ # Daily and weekly jobs' runs still running, of every project, as the
11
+ # console sees them: to cancel one, and to notice one whose process died.
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
+
16
+ # The job run with this id as an ActiveRun, when it still runs; none otherwise.
17
+ def self.with_id(db, id)
18
+ db.execute("SELECT id, commit_hash, pid, group_pid, lock_file FROM job_runs " \
19
+ "WHERE id = ? AND #{ACTIVE}", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
20
+ ActiveRun.new(id: run_id, commit_hash: sha, processes: [pid].compact, stage_groups: [group_pid].compact,
21
+ slot_lock: lock_file)
22
+ end
23
+ end
24
+
25
+ # [id, lock file] of each job run still running.
26
+ def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE #{ACTIVE}")
27
+
28
+ # What a run whose process died keeps: that it has no result, and why
29
+ # that may be. It keeps no end, since when it was found dead says
30
+ # nothing of how long it ran.
31
+ DIED_FACT = { name: "ended", extractor: "fun-ci",
32
+ value: "its process stopped before it said how the run ended (the machine slept or " \
33
+ "restarted, or something killed it), so this run has no result" }.freeze
34
+ DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
35
+
36
+ def self.cancelled(db, id) = ended(db, id, "cancelled", ACTIVE)
37
+
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.
41
+ def self.died(db, id)
42
+ ended(db, id, "cancelled", "status = 'scheduled'")
43
+ db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
44
+ [JSON.generate(DIED.to_h), id])
45
+ end
46
+
47
+ # The ids of a project's job's runs still running.
48
+ def self.running_of(db, project, name)
49
+ db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND #{ACTIVE}",
50
+ [project, name]).flatten
51
+ end
52
+
53
+ def self.ended(db, id, status, was)
54
+ db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND #{was}",
55
+ [status, Time.now.utc.iso8601(3), id])
56
+ end
57
+ private_class_method :ended
58
+ end
59
+ end
60
+ end
@@ -46,6 +46,16 @@ module FunCi
46
46
  )
47
47
  SQL
48
48
 
49
+ # Each run of a project's daily and weekly jobs (architecture.md, Daily and weekly jobs).
50
+ JOB_RUNS_TABLE = <<~SQL
51
+ CREATE TABLE IF NOT EXISTS job_runs (
52
+ id INTEGER PRIMARY KEY,
53
+ project_path TEXT, job TEXT, cadence TEXT, commit_hash TEXT, branch TEXT,
54
+ status TEXT, started_at TEXT, completed_at TEXT, lock_file TEXT, pid INTEGER, group_pid INTEGER,
55
+ exit_status INTEGER, signal TEXT, output_tail TEXT, evidence TEXT, budget INTEGER
56
+ )
57
+ SQL
58
+
49
59
  # Opening and migrating hold an exclusive lock on a file beside the
50
60
  # database, so fun-ci processes starting at once set it up one at a time.
51
61
  # SQLite's busy timeout doesn't cover the switch to WAL, and a column can
@@ -63,13 +73,13 @@ module FunCi
63
73
  %w[pipeline_runs waited_at TEXT], %w[stage_jobs exit_status INTEGER],
64
74
  %w[stage_jobs signal TEXT], %w[stage_jobs budget INTEGER], %w[stage_jobs pruned INTEGER],
65
75
  %w[stage_jobs evidence TEXT], %w[pipeline_runs fetch_pgid INTEGER],
66
- %w[pipeline_runs trunk_started_at TEXT]].freeze
76
+ %w[pipeline_runs trunk_started_at TEXT], %w[job_runs budget INTEGER]].freeze
77
+
78
+ TABLES = [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE, TRUNK_CHECKS_TABLE, TRUNK_FETCHES_TABLE, JOB_RUNS_TABLE].freeze
67
79
 
68
80
  def self.migrate!(db)
69
81
  with_setup_lock(db.filename("main")) do
70
- [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE, TRUNK_CHECKS_TABLE, TRUNK_FETCHES_TABLE].each do |table|
71
- db.execute(table)
72
- end
82
+ TABLES.each { |table| db.execute(table) }
73
83
  ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
74
84
  db.execute(NAME_DETACHED)
75
85
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "raw_outputs"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # Records a daily or weekly job's run as it runs, through the calls
10
+ # Pipeline::StageExecution makes of a stage's recorder, so a job's
11
+ # evidence is collected as a stage's is (architecture.md, Daily and
12
+ # weekly jobs). `id` is the job run's.
13
+ class JobRecorder
14
+ def initialize(db)
15
+ @db = db
16
+ end
17
+
18
+ # The process that runs the job, which a cancel stops first, and the budget it runs to.
19
+ def started_by(id, pid, budget: nil) = set(id, pid: pid, budget: budget)
20
+
21
+ # The job's script, which leads a process group of its own.
22
+ def stage_process(id, pid) = set(id, group_pid: pid)
23
+
24
+ # Unless the run was cancelled meanwhile.
25
+ def end_stage(id, status)
26
+ @db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
27
+ [status, Time.now.utc.iso8601(3), id])
28
+ end
29
+
30
+ # And the output's last lines, as a stage keeps them.
31
+ def keep_evidence(id, document) = set(id, evidence: JSON.generate(document.to_h), output_tail: document.tail)
32
+
33
+ def keep_exit(id, exit_status, signal) = set(id, exit_status: exit_status, signal: signal)
34
+ def keep_raw(id, text) = RawOutputs.for_jobs(@db.filename("main")).write(id, text)
35
+
36
+ # No stage shares a job's worktree.
37
+ def alongside(_id) = []
38
+
39
+ private
40
+
41
+ def set(id, **values)
42
+ @db.execute("UPDATE job_runs SET #{values.keys.map { |column| "#{column} = ?" }.join(", ")} WHERE id = ?",
43
+ [*values.values, id])
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../jobs/due"
5
+ require_relative "raw_outputs"
6
+ require_relative "active_jobs"
7
+
8
+ module FunCi
9
+ module Persistence
10
+ # The runs of one project's daily and weekly jobs (architecture.md, Daily
11
+ # and weekly jobs), apart from its pipeline runs.
12
+ class JobRuns
13
+ COLUMNS = %i[id project_path job cadence commit_hash branch status started_at completed_at lock_file pid
14
+ group_pid exit_status signal output_tail evidence budget].freeze
15
+ SELECT = "SELECT #{COLUMNS.join(", ")} FROM job_runs".freeze
16
+ KEPT = 10
17
+
18
+ def initialize(db, project)
19
+ @db = db
20
+ @project = project
21
+ end
22
+
23
+ # Records a run of `job` running, testing `commit` ({ sha:, branch: }),
24
+ # and answers its id, when the job is due at `now`; nil otherwise. The
25
+ # job's latest run is read and the new one written in one transaction,
26
+ # so of two claims at once only one starts it. A claim forgets the
27
+ # job's runs beyond its newest 10.
28
+ def claim(job, commit:, lock_file:, now:)
29
+ @db.transaction(:immediate) do
30
+ next nil unless Jobs::Due.new(latest(job.name), job.period, now: now).now?
31
+
32
+ insert(job, commit, lock_file, now).tap { forget_beyond(job.name) }
33
+ end
34
+ end
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
+
43
+ # The project's newest job runs, newest first.
44
+ def recent(limit:) = query("WHERE project_path = ? ORDER BY id DESC LIMIT ?", @project, limit)
45
+
46
+ # The job's newest run, or nil when it never ran.
47
+ def latest(name) = query("WHERE project_path = ? AND job = ? ORDER BY id DESC LIMIT 1", @project, name).first
48
+
49
+ # Records failed the job's runs still running, whose process is known to be gone.
50
+ def died(name) = ActiveJobs.running_of(@db, @project, name).each { |id| ActiveJobs.died(@db, id) }
51
+
52
+ private
53
+
54
+ def insert(job, commit, lock_file, now)
55
+ @db.execute("INSERT INTO job_runs (project_path, job, cadence, commit_hash, branch, status, started_at, " \
56
+ "lock_file) VALUES (?, ?, ?, ?, ?, 'running', ?, ?)",
57
+ [@project, job.name, job.cadence, commit[:sha], commit[:branch], stamp(now), lock_file])
58
+ @db.last_insert_row_id
59
+ end
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
+
66
+ def forget_beyond(name)
67
+ ids = @db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? ORDER BY id DESC " \
68
+ "LIMIT -1 OFFSET ?", [@project, name, KEPT]).flatten
69
+ @db.execute("DELETE FROM job_runs WHERE id IN (#{ids.map { "?" }.join(", ")})", ids) if ids.any?
70
+ RawOutputs.for_jobs(@db.filename("main")).delete(ids)
71
+ end
72
+
73
+ def stamp(time) = time.utc.iso8601(3)
74
+
75
+ def query(clause, *params)
76
+ @db.execute("#{SELECT} #{clause}", params).map { |row| COLUMNS.zip(row).to_h }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -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)
@@ -11,6 +11,9 @@ module FunCi
11
11
  class RawOutputs
12
12
  def self.beside(db_path) = new(File.join(File.dirname(db_path), "raw"))
13
13
 
14
+ # What failed daily and weekly jobs printed, apart, since their ids are their own.
15
+ def self.for_jobs(db_path) = new(File.join(File.dirname(db_path), "raw-jobs"))
16
+
14
17
  def initialize(dir)
15
18
  @dir = dir
16
19
  end
@@ -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
@@ -5,24 +5,43 @@ require_relative "../persistence/pipeline_recorder"
5
5
  require_relative "trigger_params"
6
6
  require_relative "../setup/project_config"
7
7
  require_relative "../trunk/first_fetch"
8
+ require_relative "../jobs/due_jobs"
9
+ require_relative "../jobs/job_fork"
8
10
 
9
11
  module FunCi
10
12
  module Pipeline
11
13
  class PipelineForker
12
- # A run started, and what to say before the project's first fetch of its trunk, if this is it.
13
- Forked = Data.define(:notice)
14
+ # A run started, what to say before the project's first fetch of its
15
+ # trunk, if this is it, and why the daily and weekly jobs didn't start,
16
+ # if they didn't.
17
+ Forked = Data.define(:notice, :jobs)
18
+
19
+ class Forked
20
+ def initialize(notice:, jobs: nil) = super
21
+ end
14
22
 
15
23
  # Answers a Forked, or false in a project not set up for fun-ci. What the
16
- # run prints goes nowhere, so the notice is worked out here, before the fork.
17
- def self.fork_pipeline(commit_hash:, branch:, db_path:)
24
+ # run prints goes nowhere, so the notice is worked out here, before the
25
+ # fork. The project's due jobs start beside the run, each in a process
26
+ # of its own (design.md, Daily and weekly jobs), once the run has
27
+ # started, and trouble starting them never stops it. jobs: what starts
28
+ # them, given the commit (by its full SHA, as the run keeps it) and the
29
+ # database's path.
30
+ def self.fork_pipeline(commit_hash:, branch:, db_path:, jobs: method(:start_due_jobs))
18
31
  return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
19
32
 
20
33
  notice = first_fetch(db_path)
21
- pid = fork do
22
- run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
23
- end
24
- Process.detach(pid)
25
- Forked.new(notice: notice)
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) })
37
+ end
38
+
39
+ # What went wrong starting the jobs, or nil.
40
+ def self.trouble
41
+ yield
42
+ nil
43
+ rescue StandardError => e
44
+ "fun-ci: the daily and weekly jobs didn't start: #{e.message}"
26
45
  end
27
46
 
28
47
  # The connection closes before the fork, which must not inherit it.
@@ -33,6 +52,19 @@ module FunCi
33
52
  db&.close
34
53
  end
35
54
 
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) }
59
+ end
60
+
61
+ def self.due_jobs(db_path)
62
+ db = Persistence::Database.connection(db_path)
63
+ Jobs::DueJobs.new(Dir.pwd, db, now: Time.now).starts
64
+ ensure
65
+ db&.close
66
+ end
67
+
36
68
  # Trigger requires this file, through TriggerCommand, so it is loaded
37
69
  # here, where it is used, rather than above.
38
70
  def self.run_in_child(commit_hash:, branch:, db_path:)
@@ -49,7 +81,7 @@ module FunCi
49
81
  Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
50
82
  io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
51
83
  end
52
- private_class_method :trigger, :first_fetch
84
+ private_class_method :trigger, :first_fetch, :due_jobs, :trouble
53
85
  end
54
86
  end
55
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