diff --git a/Gemfile.lock b/Gemfile.lock index 711fa4b..179a2f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ PATH csv json mata (~> 0.8.0) + parallel (~> 1.24) psych rails (>= 7.2.0) diff --git a/lib/perron/configuration.rb b/lib/perron/configuration.rb index 26efe6b..b506e42 100644 --- a/lib/perron/configuration.rb +++ b/lib/perron/configuration.rb @@ -17,6 +17,9 @@ def initialize @config.mode = :standalone + @config.parallel_rendering = false + @config.build_threads = nil + @config.live_reload = false @config.live_reload_watch_paths = %w[app/content app/views app/assets] @config.live_reload_skip_paths = %w[app/assets/builds] diff --git a/lib/perron/site/builder.rb b/lib/perron/site/builder.rb index beb5c0e..db12e42 100644 --- a/lib/perron/site/builder.rb +++ b/lib/perron/site/builder.rb @@ -7,6 +7,8 @@ require "perron/site/builder/paths" require "perron/site/builder/additional_routes" require "perron/site/builder/page" +require "perron/site/builder/benchmark" +require "parallel" module Perron module Site @@ -16,24 +18,52 @@ def initialize end def build + @benchmark = Benchmark.new + @benchmark.start + if Perron.configuration.mode.standalone? - puts "🧹 Cleaning previous build…" + @benchmark.phase("Clean") do + puts "🧹 Cleaning previous build…" + FileUtils.rm_rf(Dir.glob("#{@output_path}/*")) + end - FileUtils.rm_rf(Dir.glob("#{@output_path}/*")) + @benchmark.phase("Assets") do + Perron::Site::Builder::Assets.new.prepare + end - Perron::Site::Builder::Assets.new.prepare - Perron::Site::Builder::PublicFiles.new.copy + @benchmark.phase("Public files") do + Perron::Site::Builder::PublicFiles.new.copy + end end puts "\nšŸ“ Generating collections…" - paths.each { render_page(it) } + @benchmark.phase("Collect paths") do + @paths = paths + end - Perron::Site::Builder::Sitemap.new(@output_path).generate - Perron::Site::Builder::Feeds.new(@output_path).generate + if Perron.configuration.parallel_rendering + @benchmark.phase("Page rendering (parallel)") do + render_pages_in_parallel + end + else + @benchmark.phase("Page rendering (sequential)") do + render_pages_sequentially + end + end + + @benchmark.phase("Sitemap") do + Perron::Site::Builder::Sitemap.new(@output_path).generate + end + + @benchmark.phase("Feeds") do + Perron::Site::Builder::Feeds.new(@output_path).generate + end output_preview_urls + @benchmark.summary + puts "\nāœ… Build complete" end @@ -46,16 +76,46 @@ def paths end end - def render_page(path) = Perron::Site::Builder::Page.new(path).render + def render_pages_sequentially + @paths.each do |path| + result = Page.new(path, benchmark: @benchmark).render + + display_error(result) + end + end + + def render_pages_in_parallel + print_mutex = Mutex.new + + results = Parallel.map(@paths.to_a, threads: thread_count) do |path| + result = Page.new(path, benchmark: @benchmark).render + + print_mutex.synchronize { print "\e[32m.\e[0m" } + + result + end + + results.each { |result| display_error(result) if result } + end + + def display_error(result) + return if result.success + + puts "\n āŒ ERROR: Failed to generate page for '#{result.path}'. Details: #{result.error}" + end + + def thread_count + Perron.configuration.build_threads || Parallel.processor_count + end def output_preview_urls previewable_resources = Perron::Site.collections.flat_map { it.send(:load_resources) }.select(&:previewable?) - if previewable_resources.any? - puts "\nšŸ”’ Preview URLs:" - previewable_resources.each do |resource| - puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}" - end + return unless previewable_resources.any? + + puts "\nšŸ”’ Preview URLs:" + previewable_resources.each do |resource| + puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}" end end end diff --git a/lib/perron/site/builder/benchmark.rb b/lib/perron/site/builder/benchmark.rb new file mode 100644 index 0000000..a6fcf81 --- /dev/null +++ b/lib/perron/site/builder/benchmark.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Perron + module Site + class Builder + class Benchmark + def initialize + @phases = {} + @page_times = [] + @start_time = nil + end + + def start + @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def phase(name) + @phases[name] ||= {duration: 0, start: nil} + @phases[name][:start] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + yield + ensure + if @phases[name][:start] + @phases[name][:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - @phases[name][:start] + @phases[name][:start] = nil + end + end + + def record_page(path, duration) + @page_times << {path: path, duration: duration} + end + + def total_duration + return 0 unless @start_time + + Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time + end + + def summary + total = total_duration + + puts "\n" + puts "Build Performance" + puts "=" * 60 + + @phases.each do |name, data| + percentage = (total > 0) ? (data[:duration] / total * 100) : 0 + bar = render_bar(percentage) + + puts " #{name.ljust(25)} #{format("%6.2fs", data[:duration])} #{bar} #{percentage.round(1)}%" + end + + puts "-" * 60 + puts " Total pages: #{@page_times.size}" + puts " Avg per page: #{format("%.3fs", average_page_time)}" if @page_times.any? + puts " Pages/second: #{pages_per_second.round(1)}" if @page_times.any? && total > 0 + + if @page_times.size > 5 + puts "\n Slowest pages:" + + sorted = @page_times.sort_by { |entry| entry[:duration] } + sorted.last(5).reverse_each do |entry| + puts " #{entry[:path].ljust(40)} #{format("%6.3fs", entry[:duration])}" + end + end + + puts "\n" + puts " TOTAL: #{format("%.2fs", total)}" + puts "=" * 60 + end + + private + + def average_page_time + return 0 unless @page_times.any? + + @page_times.sum { |entry| entry[:duration] } / @page_times.size + end + + def pages_per_second + return 0 if @page_times.empty? || total_duration.zero? + + @page_times.size / total_duration + end + + def render_bar(percentage, width: 20) + filled = (percentage / 100 * width).round + empty = width - filled + + "#{"ā–ˆ" * filled}#{"ā–‘" * empty}" + end + end + end + end +end diff --git a/lib/perron/site/builder/page.rb b/lib/perron/site/builder/page.rb index 5ce30f0..6e78760 100644 --- a/lib/perron/site/builder/page.rb +++ b/lib/perron/site/builder/page.rb @@ -6,11 +6,17 @@ module Perron module Site class Builder class Page - def initialize(path) - @output_path, @path = Rails.root.join(Perron.configuration.output), path + Result = Struct.new(:success, :path, :error, :duration, keyword_init: true) + + def initialize(path, benchmark: nil) + @output_path = Rails.root.join(Perron.configuration.output) + @path = path + @benchmark = benchmark end def render + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + action = route_info[:action] request = ActionDispatch::Request.new(env) response = ActionDispatch::Response.new @@ -19,15 +25,27 @@ def render controller.dispatch(action, request, response) - return puts " āŒ ERROR: Request failed for '#{@path}' (Status: #{response.status})" unless response.successful? + unless response.successful? + return record_result(success: false, error: "Request failed (Status: #{response.status})", start_time: start_time) + end save_html(response.body) + + record_result(success: true, start_time: start_time) rescue => error - puts " āŒ ERROR: Failed to generate page for '#{@path}'. Details: #{error.class} - #{error.message}\n#{error.backtrace.first(3).join("\n")}" + record_result(success: false, error: "#{error.class} - #{error.message}", start_time: start_time) end private + def record_result(success:, start_time:, error: nil) + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + + @benchmark&.record_page(@path, duration) + + Result.new(success: success, path: @path, error: error, duration: duration) + end + def save_html(html) prefixless_path = @path.delete_prefix("/") diff --git a/perron.gemspec b/perron.gemspec index 6cd45c9..28643b2 100644 --- a/perron.gemspec +++ b/perron.gemspec @@ -18,8 +18,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.4.0" - spec.add_dependency "rails", ">= 7.2.0" spec.add_dependency "mata", "~> 0.8.0" + spec.add_dependency "parallel", "~> 1.24" + spec.add_dependency "rails", ">= 7.2.0" spec.add_runtime_dependency "csv" spec.add_runtime_dependency "json"