fun_ci 2.1.1-aarch64-linux → 2.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +24 -1
  4. data/lib/fun_ci/agent/command_support.rb +7 -0
  5. data/lib/fun_ci/agent/commands.rb +2 -1
  6. data/lib/fun_ci/agent/due_in.rb +21 -0
  7. data/lib/fun_ci/agent/events_command.rb +14 -7
  8. data/lib/fun_ci/agent/job_events.rb +37 -0
  9. data/lib/fun_ci/agent/job_json.rb +16 -0
  10. data/lib/fun_ci/agent/job_report.rb +85 -0
  11. data/lib/fun_ci/agent/job_why.rb +59 -0
  12. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  13. data/lib/fun_ci/agent/job_why_text.rb +51 -0
  14. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  15. data/lib/fun_ci/agent/jobs_text.rb +50 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +7 -2
  17. data/lib/fun_ci/agent/options.rb +3 -3
  18. data/lib/fun_ci/agent/output.rb +14 -2
  19. data/lib/fun_ci/agent/snapshots.rb +16 -1
  20. data/lib/fun_ci/agent/span.rb +24 -0
  21. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  22. data/lib/fun_ci/agent/status_command.rb +3 -2
  23. data/lib/fun_ci/agent/status_text.rb +19 -5
  24. data/lib/fun_ci/agent/why_command.rb +6 -2
  25. data/lib/fun_ci/cli.rb +1 -1
  26. data/lib/fun_ci/cli_help.rb +3 -1
  27. data/lib/fun_ci/console/board_data.rb +26 -13
  28. data/lib/fun_ci/console/console_state.rb +12 -6
  29. data/lib/fun_ci/console/job_message.rb +33 -0
  30. data/lib/fun_ci/console/job_order.rb +22 -0
  31. data/lib/fun_ci/console/job_rows.rb +21 -0
  32. data/lib/fun_ci/console/key_handler.rb +33 -14
  33. data/lib/fun_ci/console/paging.rb +26 -0
  34. data/lib/fun_ci/console/view.rb +18 -9
  35. data/lib/fun_ci/evidence/settings.rb +14 -7
  36. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  37. data/lib/fun_ci/jobs/due.rb +33 -0
  38. data/lib/fun_ci/jobs/due_jobs.rb +26 -0
  39. data/lib/fun_ci/jobs/folders.rb +50 -0
  40. data/lib/fun_ci/jobs/job.rb +31 -0
  41. data/lib/fun_ci/jobs/job_fork.rb +45 -0
  42. data/lib/fun_ci/jobs/job_run.rb +85 -0
  43. data/lib/fun_ci/jobs/locks.rb +30 -0
  44. data/lib/fun_ci/jobs/site.rb +10 -0
  45. data/lib/fun_ci/jobs/standings.rb +46 -0
  46. data/lib/fun_ci/jobs/state.rb +26 -0
  47. data/lib/fun_ci/persistence/active_jobs.rb +54 -0
  48. data/lib/fun_ci/persistence/database.rb +14 -4
  49. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  50. data/lib/fun_ci/persistence/job_runs.rb +68 -0
  51. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  52. data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
  53. data/lib/fun_ci/pipeline/process_runner.rb +15 -5
  54. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  55. data/lib/fun_ci/pipeline/slot.rb +1 -1
  56. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  57. data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
  58. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  59. data/lib/fun_ci/pipeline/worktrees.rb +8 -4
  60. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  61. data/lib/fun_ci/setup/project_config.rb +6 -0
  62. data/lib/fun_ci/setup/setup_checker.rb +7 -1
  63. data/lib/fun_ci/version.rb +1 -1
  64. data/libexec/fun-ci-renderer +0 -0
  65. metadata +28 -1
@@ -8,7 +8,7 @@ module FunCi
8
8
  # What is wrong with the `evidence` key of .fun-ci/config, each as a line
9
9
  # `fun-ci check` prints.
10
10
  class SettingsCheck
11
- KNOWN = %w[budget detect skip mask masking stages].freeze
11
+ KNOWN = %w[budget detect skip mask masking stages jobs].freeze
12
12
  STAGES = %w[all lint build fast slow].freeze
13
13
 
14
14
  # "2", 2, 2.5 or "2s" as seconds, or nil.
@@ -28,7 +28,7 @@ module FunCi
28
28
  return [] if @raw.nil?
29
29
  return ["evidence must be a mapping, not #{@raw.inspect}"] unless @raw.is_a?(Hash)
30
30
 
31
- unknown + budget + stages
31
+ unknown + budget + stages + jobs
32
32
  end
33
33
 
34
34
  private
@@ -48,12 +48,23 @@ module FunCi
48
48
  lists.flat_map { |stage, entries| stage_errors(stage, entries) }
49
49
  end
50
50
 
51
+ def jobs
52
+ lists = @raw.fetch("jobs", {})
53
+ return ["evidence.jobs must be a mapping of job to entries"] unless lists.is_a?(Hash)
54
+
55
+ lists.flat_map { |job, entries| entry_errors("evidence.jobs.#{job}", entries) }
56
+ end
57
+
58
+ def entry_errors(where, entries)
59
+ Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "#{where}: #{message}" }
60
+ end
61
+
51
62
  def stage_errors(stage, entries)
52
63
  unless STAGES.include?(stage)
53
64
  return ["evidence.stages has no stage '#{stage}': use all, lint, build, fast or slow"]
54
65
  end
55
66
 
56
- Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "evidence.stages.#{stage}: #{message}" }
67
+ entry_errors("evidence.stages.#{stage}", entries)
57
68
  end
58
69
 
59
70
  def refusal(entry)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # Whether a job is due, from its latest run alone ({ status:, started_at: },
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).
11
+ class Due
12
+ def initialize(latest, period, now:)
13
+ @latest = latest
14
+ @period = period
15
+ @now = now
16
+ end
17
+
18
+ def now?
19
+ return true if @latest.nil? || @latest[:status] == "cancelled"
20
+
21
+ !running? && again <= @now
22
+ end
23
+
24
+ # When it is due again, or nil while it is due now or still runs.
25
+ def at = now? || running? ? nil : again
26
+
27
+ private
28
+
29
+ def running? = @latest[:status] == "running"
30
+ def again = Time.parse(@latest[:started_at]) + @period
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "standings"
4
+ require_relative "../pipeline/run_canceller"
5
+
6
+ module FunCi
7
+ module Jobs
8
+ # The project's jobs that are due at `now`, read from the database without
9
+ # taking any lock: who starts a job is decided again, under its lock, by
10
+ # the process that runs it. A job left running by a process that died is
11
+ # recorded failed first, or it would never be due again, and no process
12
+ # would start to find it dead.
13
+ class DueJobs
14
+ def initialize(project, db, now:)
15
+ @project = project
16
+ @db = db
17
+ @now = now
18
+ end
19
+
20
+ def list
21
+ Pipeline::RunCanceller.new.record_dead_jobs(@db)
22
+ Standings.new(@db, @project, now: @now).all.select(&:due_now?).map(&:job)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # The jobs a project keeps in .fun-ci/daily/ and .fun-ci/weekly/: each
8
+ # executable `<name>.sh`. A script that can't run, or a name in both
9
+ # folders, is no job; #errors says why, for `fun-ci check`.
10
+ class Folders
11
+ CADENCES = Job::PERIODS.keys
12
+ # A name a command line keeps whole, since agents paste it: fun-ci why --job NAME.
13
+ NAME = /\A[A-Za-z0-9._-]+\z/
14
+
15
+ def initialize(project_root)
16
+ @fun_ci_dir = File.join(project_root, ".fun-ci")
17
+ end
18
+
19
+ # By name.
20
+ def jobs
21
+ all = scripts
22
+ twice = twice(all)
23
+ all.select { |job| runnable?(job) && NAME.match?(job.name) && !twice.include?(job.name) }.sort_by(&:name)
24
+ end
25
+
26
+ def errors
27
+ all = scripts
28
+ all.reject { |job| runnable?(job) }.map { |job| "#{path(job)} is not executable" } +
29
+ all.reject { |job| NAME.match?(job.name) }.map { |job| misnamed(job) } +
30
+ twice(all).map { |name| "the job #{name} is in both .fun-ci/daily/ and .fun-ci/weekly/: rename one" }
31
+ end
32
+
33
+ private
34
+
35
+ def runnable?(job) = File.executable?(job.script)
36
+ def path(job) = ".fun-ci/#{job.cadence}/#{job.name}.sh"
37
+ def misnamed(job) = "the job '#{job.name}' needs a name of letters, digits, '.', '_' and '-': rename #{path(job)}"
38
+
39
+ def twice(all) = all.group_by(&:name).select { |_, jobs| jobs.size > 1 }.keys.sort
40
+
41
+ def scripts
42
+ CADENCES.flat_map do |cadence|
43
+ Dir.glob("*.sh", base: File.join(@fun_ci_dir, cadence)).map do |file|
44
+ Job.new(name: File.basename(file, ".sh"), cadence: cadence, script: File.join(@fun_ci_dir, cadence, file))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # A script a project runs at most once a day or once a week, named after
8
+ # it (design.md, Daily and weekly jobs). `cadence` is "daily" or "weekly".
9
+ Job = Data.define(:name, :cadence, :script)
10
+
11
+ class Job
12
+ PERIODS = { "daily" => 86_400, "weekly" => 604_800 }.freeze
13
+ # Every job's, the weekly ones' too.
14
+ BUDGET = 86_400
15
+ # How a job is named where a stage would be.
16
+ STAGE = "jobs/"
17
+
18
+ # The name of the job a stage name names, or nil for a pipeline stage's.
19
+ def self.named_by(stage) = (stage.delete_prefix(STAGE) if stage.start_with?(STAGE))
20
+
21
+ # Seconds from one run's start until the job is due again.
22
+ def period = PERIODS.fetch(cadence)
23
+
24
+ # What evidence knows it by: `evidence: jobs: <name>:` in .fun-ci/config.
25
+ def stage = "#{STAGE}#{name}"
26
+
27
+ # The shell command that runs it on a commit, its path quoted.
28
+ def command(sha) = "#{Shellwords.escape(script)} #{sha}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_run"
4
+ require_relative "../persistence/database"
5
+ require_relative "../pipeline/worktrees"
6
+
7
+ module FunCi
8
+ module Jobs
9
+ # Starts a job in a process of its own (architecture.md, Daily and weekly
10
+ # jobs). The trigger that forks it exits when its pipeline ends, and the
11
+ # terminal of the commit may close; a job can run for hours, so it leads a
12
+ # session of its own, prints nowhere, and opens its own connection, since
13
+ # a child must not inherit one.
14
+ module JobFork
15
+ def self.start(job, commit, project:, db_path:)
16
+ Process.detach(fork { run_in_child(job, commit, project, db_path) })
17
+ end
18
+
19
+ def self.run_in_child(job, commit, project, db_path)
20
+ Process.setsid
21
+ silence
22
+ db = Persistence::Database.connection(db_path)
23
+ JobRun.new(job, commit, site(project, db), Pipeline::Seams.new).start
24
+ ensure
25
+ db&.close
26
+ end
27
+
28
+ # The process's own descriptors, whatever $stdout and $stderr stand for
29
+ # (a test's StringIO, a guard's file), onto an open /dev/null, since
30
+ # reopening by path can't change a stream's access mode.
31
+ def self.silence
32
+ File.open(File::NULL, File::RDWR) do |null|
33
+ (0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
34
+ end
35
+ end
36
+
37
+ def self.site(project, db)
38
+ worktrees = Pipeline::Worktrees.new(project)
39
+ Site.new(project: project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
40
+ clock: -> { Time.now })
41
+ end
42
+ private_class_method :run_in_child, :silence, :site
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,85 @@
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, and the job runs on the commit in its own worktree, recorded as a
18
+ # 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
+ def start
29
+ slot = @site.locks.take(@job.name)
30
+ slot && holding(slot) { claimed(slot) }
31
+ end
32
+
33
+ private
34
+
35
+ def runs = Persistence::JobRuns.new(@site.db, @site.project)
36
+ def recorder = Persistence::JobRecorder.new(@site.db)
37
+
38
+ def holding(slot)
39
+ yield
40
+ ensure
41
+ slot.release
42
+ end
43
+
44
+ def claimed(slot)
45
+ runs.died(@job.name)
46
+ id = runs.claim(@job, commit: @commit.to_h, lock_file: slot.lock_file, now: @site.clock.call)
47
+ id && execute(slot, id)
48
+ end
49
+
50
+ def execute(slot, id)
51
+ recorder.started_by(id, Process.pid, budget: budget)
52
+ @site.worktrees.check_out(slot.path, @commit.sha)
53
+ execution(slot).run(@job.stage, command(slot.path), recorder, id)
54
+ id
55
+ rescue StandardError => e
56
+ failed(id, e.message)
57
+ end
58
+
59
+ # The script holds the job's lock too, so the job reads as alive while
60
+ # any of it runs, its runner gone or not.
61
+ # A day for every job, unless a test says otherwise.
62
+ def seams = @seams.with(time_budgets: { @job.stage => Job::BUDGET }.merge(@seams.time_budgets))
63
+ def budget = seams.budgets[@job.stage]
64
+
65
+ def execution(slot)
66
+ Pipeline::StageExecution.new(seams: seams, dir: slot.path, commit: @commit,
67
+ launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock] })
68
+ end
69
+
70
+ # The commit's own copy of the script when it has one, as a pipeline's stages are.
71
+ def command(dir)
72
+ committed = File.join(dir, ".fun-ci", @job.cadence, "#{@job.name}.sh")
73
+ (File.exist?(committed) ? @job.with(script: committed) : @job).command(@commit.sha)
74
+ end
75
+
76
+ # Whatever stopped the run, so it isn't left running: a run left running
77
+ # is only found dead once its lock is free, and then says nothing of why.
78
+ def failed(id, problem)
79
+ recorder.keep_evidence(id, Evidence::Document.broken(problem))
80
+ recorder.end_stage(id, "failed")
81
+ id
82
+ end
83
+ end
84
+ end
85
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Jobs
5
+ # Where a project's jobs run and are recorded: the project's path, the
6
+ # database, its Pipeline::Worktrees (#check_out), its job Locks, and
7
+ # `clock`, which answers the time now.
8
+ Site = Data.define(:project, :db, :worktrees, :locks, :clock)
9
+ end
10
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "folders"
4
+ require_relative "due"
5
+ require_relative "state"
6
+ require_relative "../persistence/job_runs"
7
+
8
+ module FunCi
9
+ module Jobs
10
+ # Where a job stands: its project, the job, its latest run as job_runs
11
+ # keeps it (nil while it never ran), and Due, which says whether the
12
+ # next commit starts it and when it is due again.
13
+ Standing = Data.define(:project, :job, :run, :due)
14
+
15
+ class Standing
16
+ def name = job.name
17
+ def cadence = job.cadence
18
+ def state = State.of(run)
19
+ def needs_you? = State.needs_you?(state)
20
+ def due_now? = due.now?
21
+ def due_at = due.at
22
+ end
23
+
24
+ # Where each of a project's jobs stands at `now`, read once for the
25
+ # console, the agent commands and the trigger.
26
+ class Standings
27
+ def initialize(db, project, now:)
28
+ @db = db
29
+ @project = project
30
+ @now = now
31
+ end
32
+
33
+ # By name.
34
+ def all
35
+ runs = Persistence::JobRuns.new(@db, @project)
36
+ Folders.new(@project).jobs.map { |job| standing(job, runs.latest(job.name)) }
37
+ end
38
+
39
+ private
40
+
41
+ def standing(job, run)
42
+ Standing.new(project: @project, job: job, run: run, due: Due.new(run, job.period, now: @now))
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,26 @@
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
+ # `running`, `passed`, `failed`, `over_budget`, `cancelled`, or `lost`
8
+ # when its process died before it said how the run ended. Each surface
9
+ # (the console, the agent commands) says these in its own words.
10
+ module State
11
+ OF_STATUS = { "completed" => "passed", "failed" => "failed", "timed_out" => "over_budget",
12
+ "running" => "running", "cancelled" => "cancelled" }.freeze
13
+ NEEDS_YOU = %w[failed over_budget lost].freeze
14
+
15
+ # run: { status:, completed_at: }, or nil when the job never ran.
16
+ def self.of(run)
17
+ return "due" unless run
18
+ return "lost" if run[:status] == "failed" && run[:completed_at].nil?
19
+
20
+ OF_STATUS.fetch(run[:status], "unknown")
21
+ end
22
+
23
+ def self.needs_you?(state) = NEEDS_YOU.include?(state)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
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
+ # The job run with this id as an ActiveRun, when it still runs; none otherwise.
14
+ def self.with_id(db, id)
15
+ db.execute("SELECT id, commit_hash, pid, group_pid, lock_file FROM job_runs " \
16
+ "WHERE id = ? AND status = 'running'", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
17
+ ActiveRun.new(id: run_id, commit_hash: sha, processes: [pid].compact, stage_groups: [group_pid].compact,
18
+ slot_lock: lock_file)
19
+ end
20
+ end
21
+
22
+ # [id, lock file] of each job run still running.
23
+ def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE status = 'running'")
24
+
25
+ # What a run whose process died keeps: that it has no result, and why
26
+ # that may be. It keeps no end, since when it was found dead says
27
+ # nothing of how long it ran.
28
+ DIED_FACT = { name: "ended", extractor: "fun-ci",
29
+ value: "its process stopped before it said how the run ended (the machine slept or " \
30
+ "restarted, or something killed it), so this run has no result" }.freeze
31
+ DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
32
+
33
+ def self.cancelled(db, id) = ended(db, id, "cancelled")
34
+
35
+ # A run whose process is gone without recording how it ended: failed, with no end.
36
+ def self.died(db, id)
37
+ db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
38
+ [JSON.generate(DIED.to_h), id])
39
+ end
40
+
41
+ # The ids of a project's job's runs still running.
42
+ def self.running_of(db, project, name)
43
+ db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND status = 'running'",
44
+ [project, name]).flatten
45
+ end
46
+
47
+ def self.ended(db, id, status)
48
+ db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
49
+ [status, Time.now.utc.iso8601(3), id])
50
+ end
51
+ private_class_method :ended
52
+ end
53
+ end
54
+ 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,68 @@
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
+ # The project's newest job runs, newest first.
37
+ def recent(limit:) = query("WHERE project_path = ? ORDER BY id DESC LIMIT ?", @project, limit)
38
+
39
+ # The job's newest run, or nil when it never ran.
40
+ def latest(name) = query("WHERE project_path = ? AND job = ? ORDER BY id DESC LIMIT 1", @project, name).first
41
+
42
+ # Records failed the job's runs still running, whose process is known to be gone.
43
+ def died(name) = ActiveJobs.running_of(@db, @project, name).each { |id| ActiveJobs.died(@db, id) }
44
+
45
+ private
46
+
47
+ def insert(job, commit, lock_file, now)
48
+ @db.execute("INSERT INTO job_runs (project_path, job, cadence, commit_hash, branch, status, started_at, " \
49
+ "lock_file) VALUES (?, ?, ?, ?, ?, 'running', ?, ?)",
50
+ [@project, job.name, job.cadence, commit[:sha], commit[:branch], stamp(now), lock_file])
51
+ @db.last_insert_row_id
52
+ end
53
+
54
+ def forget_beyond(name)
55
+ ids = @db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? ORDER BY id DESC " \
56
+ "LIMIT -1 OFFSET ?", [@project, name, KEPT]).flatten
57
+ @db.execute("DELETE FROM job_runs WHERE id IN (#{ids.map { "?" }.join(", ")})", ids) if ids.any?
58
+ RawOutputs.for_jobs(@db.filename("main")).delete(ids)
59
+ end
60
+
61
+ def stamp(time) = time.utc.iso8601(3)
62
+
63
+ def query(clause, *params)
64
+ @db.execute("#{SELECT} #{clause}", params).map { |row| COLUMNS.zip(row).to_h }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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