highlight dependencies in canvas:compile_assets

refs FOO-966

as we're working on extracting Gradebook into its own client_app, the
canvas:compile_assets rake task had to be adjusted to build it too, then
i realized that the canvas_quizzes app has to be built _before_ we
generate i18n phrases while Gradebook (and canvas) have to go _after_
that task

the rake task did not appreciate any more complexity, so instead I
tried to focus on making the dependencies between these sub-tasks
clearer and at the same time maintain the parallel execution

changes:

- it's now possible to build a specific client_app via rake tasks
- the symlink needed for canvas_quizzes client app is now restored
  correctly in case of a failure during the i18n rake task. Previously,
  you had to restore it by hand before running any of the tasks again
- in case a client_app does not implement a `script/build`, we call its
  "npm build" script instead (eg `yarn run build`)

test plan:

- run "RAILS_LOAD_ALL_LOCALES=1 rake canvas:compile_assets" before you
  check out the patch and note the time taken
- check out the patch and re-run the task, verify it still works and
  that the time taken is not longer than what it was (if anything, it
  should be a little faster since we're batching more work)

Change-Id: I65e675ceeea93643a1c679ff29039ed5ef71b53d
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/247902
Reviewed-by: Simon Williams <simon@instructure.com>
QA-Review: Simon Williams <simon@instructure.com>
Product-Review: Simon Williams <simon@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Ahmad Amireh 2020-09-16 16:01:56 -06:00
parent f9ee94c717
commit 581baf6e37
5 changed files with 338 additions and 84 deletions

View File

@ -98,7 +98,7 @@ module.exports = {
// the unlink/symlink uglieness is a temporary hack to get around our circular
// symlinks. we should just remove the symlinks
fs.unlinkSync('./public/javascripts/symlink_to_node_modules')
Commands.run(argv._[0], argv) || process.exit(1);
Commands.run(argv._[0], argv) || (process.exitCode = 1);
fs.symlinkSync('../../node_modules', './public/javascripts/symlink_to_node_modules')
}

148
lib/rake/task_graph.rb Normal file
View File

@ -0,0 +1,148 @@
#
# Copyright (C) 2020 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module Rake
# An API for running a large number of Rake tasks that may have dependencies
# between them. The graph resolves the dependencies, orders the tasks and
# partitions them in arrays, allowing you to run each batch in parallel
# or all tasks serially.
#
# The API tries to stay as close to Rake's as possible.
#
# == Usage
#
# batches = Rake::TaskGraph.draw do
# task 'a' => []
# task 'b' => ['a']
# task 'c' => []
# task 'd' => ['c','b']
# end
# # => [ ['a','c'], ['b'], ['d'] ]
#
# # run all tasks in a batch in parallel:
# batches.each do |tasks|
# Parallel.each(tasks) { |name| Rake::Task[name].invoke }
# end
#
# # or, run all tasks serially and in the right order:
# batches.flatten.each do |task|
# Rake::Task[task].invoke
# end
#
# == Options
#
# You can transform a "node", which is a string by default, into a different
# value by passing a block that the graph will yield to when it's time to
# insert the node into a batch:
#
# TaskGraph.draw do
# task 'a' do
# 5
# end
# end.to_a
# # => [ 5 ]
#
class TaskGraph
IDENTITY = ->(x) { x }
attr_reader :nodes, :transformers
def self.draw(&builder)
new.tap { |x| x.instance_exec(&builder) }.batches
end
def initialize
@nodes = {}
@transformers = {}
end
def task(name_and_deps, &transformer)
name, deps = if name_and_deps.is_a?(Hash)
name_and_deps.first
else
[name_and_deps, []]
end
@nodes[name] = deps
@transformers[name] = transformer || IDENTITY
end
def batches
ensure_all_nodes_are_defined!
to_take = nodes.keys
[].tap do |batches|
to_take.size.times do # cap iterations just in case
batch = to_take.reduce([]) do |acc, node|
take_or_resolve(node, acc, to_take)
end
if batch.empty?
break # we're done
else
to_take -= batch
batches << batch.map { |x| @transformers[x][x] }
end
end
end
end
private
def ensure_all_nodes_are_defined!
undefined = nodes.reduce([]) do |errors, (_node, deps)|
errors + deps.select { |dep| !nodes.key?(dep) }
end
if undefined.any?
fail <<~ERR
The following nodes are listed as dependents but were not defined:
- #{undefined.uniq.join("\n - ")}
ERR
end
end
def take_or_resolve(node, batch, to_take, visited = [])
if visited.include?(node)
fail "node \"#{node}\" has a self or circular dependency"
end
# don't dupe if we already took it this pass (e.g. as a dep):
if batch.include?(node)
return batch
end
unresolved_deps = to_take & nodes[node]
# assign to this batch if all deps are satisfied:
if unresolved_deps.empty?
return batch.push(node)
end
# try to resolve as many of the deps as possible in this pass and retry
# ourselves in the next:
unresolved_deps.reduce(batch) do |acc, dep|
# take_or_resolve(dep, acc, to_take, visited.push(node))
take_or_resolve(dep, acc, to_take, visited + [node])
end
end
end
end

View File

@ -1,3 +1,6 @@
require 'rake/task_graph'
require 'parallel'
$canvas_tasks_loaded ||= false
unless $canvas_tasks_loaded
$canvas_tasks_loaded = true
@ -5,7 +8,7 @@ $canvas_tasks_loaded = true
def log_time(name, &block)
puts "--> Starting: '#{name}'"
time = Benchmark.realtime(&block)
puts "--> Finished: '#{name}' in #{time}"
puts "--> Finished: '#{name}' in #{time.round(2)}s"
time
end
@ -18,80 +21,78 @@ end
namespace :canvas do
desc "Compile javascript and css assets."
task :compile_assets do |t, args|
# opt out
npm_install = ENV["COMPILE_ASSETS_NPM_INSTALL"] != "0"
compile_css = ENV["COMPILE_ASSETS_CSS"] != "0"
build_styleguide = ENV["COMPILE_ASSETS_STYLEGUIDE"] != "0"
build_webpack = ENV["COMPILE_ASSETS_BUILD_JS"] != "0"
build_api_docs = ENV["COMPILE_ASSETS_API_DOCS"] != "0"
build_css = ENV["COMPILE_ASSETS_CSS"] != "0"
build_styleguide = ENV["COMPILE_ASSETS_STYLEGUIDE"] != "0"
build_js = ENV["COMPILE_ASSETS_BUILD_JS"] != "0"
build_prod_js = ENV['RAILS_ENV'] == 'production' || ENV['USE_OPTIMIZED_JS'] == 'true' || ENV['USE_OPTIMIZED_JS'] == 'True'
# build dev bundles even in prod mode so you can debug with ?optimized_js=0
# query string (except for on jenkins where we set JS_BUILD_NO_UGLIFY anyway
# so there's no need for an unminified fallback)
build_dev_js = !build_prod_js || ENV['JS_BUILD_NO_UGLIFY'] == "1"
if npm_install
log_time('Making sure node_modules are up to date') {
Rake::Task['js:yarn_install'].invoke
}
end
raise "Error running gulp rev" unless system('yarn run gulp rev')
if compile_css
# public/dist/brandable_css/brandable_css_bundles_with_deps.json needs
# to exist before we run handlebars stuff, so we have to do this first
Rake::Task['css:compile'].invoke
end
require 'parallel'
tasks = Hash.new
if build_styleguide
tasks["css:styleguide"] = -> {
Rake::Task['css:styleguide'].invoke
}
end
Rake::Task['js:build_client_apps'].invoke
generate_tasks = []
generate_tasks << 'i18n:generate_js' if build_webpack
build_tasks = []
if build_webpack
# build dev bundles even in prod mode so you can debug with ?optimized_js=0 query string
# (except for on jenkins where we set JS_BUILD_NO_UGLIFY anyway so there's no need for an unminified fallback)
build_prod = ENV['RAILS_ENV'] == 'production' || ENV['USE_OPTIMIZED_JS'] == 'true' || ENV['USE_OPTIMIZED_JS'] == 'True'
dont_need_dev_fallback = build_prod && ENV['JS_BUILD_NO_UGLIFY'] == "1"
build_tasks << 'js:webpack_development' unless dont_need_dev_fallback
build_tasks << 'js:webpack_production' if build_prod
end
msg = "run " + (generate_tasks + build_tasks).join(", ")
tasks[msg] = -> {
if generate_tasks.any?
Parallel.each(generate_tasks, in_processes: parallel_processes) do |name|
log_time(name) { Rake::Task[name].invoke }
end
batches = Rake::TaskGraph.draw do
task 'css:compile' if build_css
task 'css:styleguide' if build_styleguide
task 'doc:api' if build_api_docs
task 'js:yarn_install' if npm_install
task 'gulp:rev' do |name|
{ name: name, runner: -> { system('yarn run gulp rev') } }
end
if build_tasks.any?
Parallel.each(build_tasks, in_threads: parallel_processes) do |name|
log_time(name) { Rake::Task[name].invoke }
end
end
}
task 'i18n:generate_js' => [
# canvas_quizzes is quirky in that we can only extract its phrases from
# its build artifacts and not from its source! this is unlike other
# client apps
'js:build_client_app[canvas_quizzes]'
] if build_js
if build_api_docs
tasks["Generate documentation [yardoc]"] = -> {
Rake::Task['doc:api'].invoke
}
task 'js:build_client_app[canvas_quizzes]' => [ 'css:compile' ] do |name|
{
name: name,
runner: -> { Rake::Task['js:build_client_app'].invoke('canvas_quizzes') }
}
end if build_js
task 'js:webpack_development' => [
# public/dist/brandable_css/brandable_css_bundles_with_deps.json needs
# to exist before we run handlebars stuff, so we have to do this first
'css:compile',
'i18n:generate_js',
'js:build_client_app[canvas_quizzes]',
] if build_js && build_dev_js
task 'js:webpack_production' => [
'css:compile',
'i18n:generate_js',
'js:build_client_app[canvas_quizzes]',
] if build_js && build_prod_js
end
times = nil
batch_times = []
real_time = Benchmark.realtime do
times = Parallel.map(tasks, :in_processes => parallel_processes) do |name, lamduh|
log_time(name) { lamduh.call }
batches.each do |tasks|
batch_times += Parallel.map(tasks, :in_processes => parallel_processes) do |task|
name, runner = if task.is_a?(Hash)
task.values_at(:name, :runner)
else
[task, ->() { Rake::Task[task].invoke }]
end
log_time(name, &runner)
end
end
end
combined_time = times.reduce(:+)
puts "Finished compiling assets in #{real_time}. parallelism saved #{combined_time - real_time} (#{real_time.to_f / combined_time.to_f * 100.0}%)"
combined_time = batch_times.reduce(:+)
puts (
"Finished compiling assets in #{real_time.round(2)}s. " +
"Parallelism saved #{(combined_time - real_time).round(2)}s " +
"(#{(real_time.to_f / combined_time.to_f * 100.0).round(2)}%)"
)
end
desc "Just compile css and js for development"

View File

@ -4,38 +4,45 @@ namespace :js do
desc "Build client_apps"
task :build_client_apps do
Dir.glob("./client_apps/*/").each do |app_dir|
Rake::Task['js:build_client_app'].invoke(File.basename(app_dir))
end
end
desc "Build a specific client_app"
task :build_client_app, [:app_name] do |t, app_name:|
require 'config/initializers/client_app_symlinks'
npm_install = ENV["COMPILE_ASSETS_NPM_INSTALL"] != "0"
Dir.glob('./client_apps/*/').each do |app_dir|
app_name = File.basename(app_dir)
Dir.chdir("./client_apps/#{app_name}") do
puts "Building client app '#{app_name}'"
Dir.chdir(app_dir) do
puts "Building client app '#{app_name}'"
if npm_install && File.exists?('./package.json')
output = system 'yarn install --pure-lockfile || yarn install --pure-lockfile --network-concurrency 1'
unless $?.exitstatus == 0
puts "INSTALL FAILURE:\n#{output}"
raise "Package installation failure for client app #{app_name}"
end
end
puts "\tRunning 'yarn run build'..."
output = `./script/build`
if npm_install && File.exists?('./package.json')
output = system 'yarn install --pure-lockfile || yarn install --pure-lockfile --network-concurrency 1'
unless $?.exitstatus == 0
puts "BUILD FAILURE:\n#{output}"
raise "Build script failed for client app #{app_name}"
puts "INSTALL FAILURE:\n#{output}"
raise "Package installation failure for client app #{app_name}"
end
puts "Client app '#{app_name}' was built successfully."
end
puts "\tRunning 'yarn run build'..."
output = if File.exists?('./script/build')
`./script/build`
else
`yarn run build`
end
unless $?.exitstatus == 0
puts "BUILD FAILURE:\n#{output}"
raise "Build script failed for client app #{app_name}"
end
puts "Client app '#{app_name}' was built successfully."
end
maintain_client_app_symlinks
end
desc "Build development webpack js"
task :webpack_development do
require 'config/initializers/plugin_symlinks'

View File

@ -0,0 +1,98 @@
require 'spec_helper'
require 'rake/task_graph'
describe Rake::TaskGraph do
it 'works' do
batches = described_class.draw { task 'a' }
expect(batches.length).to eq(1)
expect(batches[0]).to eq(['a'])
end
it 'resolves deps' do
batches = described_class.draw do
task 'a'
task 'b' => ['a']
task 'c'
task 'd' => ['c','b']
end
expect(batches).to eq([
['a','c'],
['b'],
['d']
])
end
it 'does not dupe nodes' do
batches = described_class.draw do
task 'a' => []
task 'b' => ['a','a']
task 'c'
task 'd' => ['c','b']
task 'e' => ['a']
end
expect(batches).to eq([
['a','c'],
['b','e'],
['d']
])
end
it 'is pure' do
subject.task 'a'
subject.task 'b' => ['a']
subject.task 'c' => []
subject.task 'd' => ['a','b','c']
expect(subject.batches).to eq(subject.batches)
end
it 'loses no nodes in a sequence' do
batches = described_class.draw do
task 'a'
task 'b' => ['a']
task 'c' => ['b']
task 'd' => ['c']
end
expect(batches).to eq([
['a'],
['b'],
['c'],
['d'],
])
end
it 'transforms a node' do
batches = described_class.draw do
task 'b' => ['a']
task 'a' do
5
end
end
expect(batches).to eq([ [5], ['b'] ])
end
it 'whines on self-deps' do
expect {
described_class.draw { task 'a' => ['a'] }
}.to raise_error(/has a self or circular dependency/)
end
it 'whines on circular deps' do
expect {
described_class.draw do
task 'a' => ['b']
task 'b' => ['a']
end
}.to raise_error(/has a self or circular dependency/)
end
it 'whines if a dependency is undefined' do
expect {
described_class.draw { task 'a' => ['b'] }
}.to raise_error(/but were not defined/)
end
end