diff --git a/app/jobs/periodic_job.rb b/app/jobs/periodic_job.rb new file mode 100644 index 00000000..8d9453a3 --- /dev/null +++ b/app/jobs/periodic_job.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Una tarea que se corre periódicamente +class PeriodicJob < ApplicationJob + class RunAgainException < StandardError; end + + STARTING_INTERVAL = Stat::INTERVALS.first + + # Tener el sitio a mano + attr_reader :site + + # Descartar y notificar si pasó algo más. + # + # XXX: En realidad deberíamos seguir reintentando? + discard_on(StandardError) do |_, error| + ExceptionNotifier.notify_exception(error) + end + + # Correr indefinidamente una vez por hora. + # + # XXX: El orden importa, si el descarte viene después, nunca se va a + # reintentar. + retry_on(PeriodicJob::RunAgainException, wait: 1.try(STARTING_INTERVAL), attempts: Float::INFINITY, jitter: 0) + + private + + # Las clases que implementen esta tienen que usar este método al + # terminar. + def run_again! + raise PeriodicJob::RunAgainException, 'Reintentando' + end + + # El intervalo de inicio + # + # @return [Symbol] + def starting_interval + STARTING_INTERVAL + end + + # La última recolección de estadísticas o empezar desde el principio + # de los tiempos. + # + # @return [Stat] + def last_stat + @last_stat ||= site.stats.where(name: stat_name).last || + site.stats.build(created_at: Time.new(1970, 1, 1)) + end + + # Devuelve el comienzo del intervalo + # + # @return [Time] + def beginning_of_interval + @beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}") + end +end diff --git a/app/jobs/stat_collection_job.rb b/app/jobs/stat_collection_job.rb index a49a1635..2aa8d702 100644 --- a/app/jobs/stat_collection_job.rb +++ b/app/jobs/stat_collection_job.rb @@ -3,74 +3,65 @@ # Genera resúmenes de información para poder mostrar estadísticas y se # corre regularmente a sí misma. class StatCollectionJob < ApplicationJob - class CrontabException < StandardError; end + STAT_NAME = 'stat_collection_job' - # Descartar y notificar si pasó algo más. - # - # XXX: En realidad deberíamos seguir reintentando? - discard_on(Exception) do |_, error| - ExceptionNotifier.notify_exception error - end + def perform(site_id:, once: true) + @site = Site.find site_id - # Correr indefinidamente una vez por hora. - # - # XXX: El orden importa, si el descarte viene después, nunca se va a - # reintentar. - retry_on(StatCollectionJob::CrontabException, wait: 1.hour, attempts: Float::INFINITY, jitter: 0) + scope.rollup('builds', **options) - COLUMNS = %i[uri].freeze + scope.rollup('space_used', **options) do |rollup| + rollup.average(:bytes) + end - def perform(once: false) - Site.find_each do |site| - hostnames = [site.hostname, site.alternative_hostnames].flatten + scope.rollup('build_time', **options) do |rollup| + rollup.average(:seconds) + end - # Usamos el primero porque luego podemos hacer un rollup recursivo - options = { interval: Stat::INTERVALS.first } + # XXX: Es correcto promediar promedios? + Stat::INTERVALS.reduce do |previous, current| + rollup(name: 'builds', interval_previous: previous, interval: current) + rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average) + rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average) - # Visitas por hostname - hostnames.each do |hostname| - AccessLog.where(host: hostname).completed_requests.non_robots.pages.group(:host).rollup('host', **options) - - combined_columns(hostname, **options) - end - - stats_by_site(site, **options) + current end # Registrar que se hicieron todas las recolecciones - Stat.create! + site.stats.create! name: STAT_NAME - raise CrontabException unless once + run_again! unless once end private - # Combinación de columnas - def combined_columns(hostname, **options) - where = { host: hostname } - - COLUMNS.each do |column| - AccessLog.where(host: hostname).pluck(Arel.sql("distinct #{column}")).each do |value| - where[column] = value - - AccessLog.where(**where).completed_requests.non_robots.group(:host, column).rollup("host|#{column}", **options) - end - end + # Genera un rollup recursivo en base al período anterior y aplica una + # operación. + # + # @return [NilClass] + def rollup(name:, interval_previous:, interval:, operation: :sum) + Rollup.where(name: name, interval: interval_previous) + .where_dimensions(site_id: site.id) + .group("dimensions->'site_id'") + .rollup(name, interval: interval, update: true) do |rollup| + rollup.try(:operation, :value) + end end - # Uso de recursos por cada sitio. + # Los registros a procesar # - # XXX: En realidad se agrupan por el deploy_id, que siempre será el - # del DeployLocal. - def stats_by_site(site, **options) - site.build_stats.jekyll.group(:deploy_id).rollup('builds', **options) + # @return [ActiveRecord::Relation] + def scope + @scope ||= site.build_stats + .jekyll + .where('created_at => ?', beginning_of_interval) + .group(:site_id) + end - site.build_stats.jekyll.group(:deploy_id).rollup('space_used', **options) do |rollup| - rollup.average(:bytes) - end - - site.build_stats.jekyll.group(:deploy_id).rollup('build_time', **options) do |rollup| - rollup.average(:seconds) - end + # Las opciones por defecto + # + # @return [Hash] + def options + @options ||= { interval: starting_interval, update: true } end end diff --git a/app/jobs/uri_collection_job.rb b/app/jobs/uri_collection_job.rb index 79b83644..9ec333cd 100644 --- a/app/jobs/uri_collection_job.rb +++ b/app/jobs/uri_collection_job.rb @@ -1,16 +1,105 @@ # frozen_string_literal: true # Procesar una lista de URIs para una lista de dominios. Esto nos -# permite procesar estadísticas a demanada. -class UriCollectionJob < ApplicationJob - def perform(hostnames:, file:) - uris = File.read(file).split("\n") +# permite procesar estadísticas a demanda. +# +# Hay varias cosas acá que van a convertirse en métodos propios, como la +# detección de URIs de un sitio (aunque la versión actual detecta todas +# las páginas y no solo las de posts como tenemos planeado, hay que +# resolver eso). +# +# Los hostnames de un sitio van a poder obtenerse a partir de +# Site#hostnames con la garantía de que son únicos. +class UriCollectionJob < PeriodicJob + # Ignoramos imágenes porque suelen ser demasiadas y no aportan a las + # estadísticas. + IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze + STAT_NAME = 'uri_collection_job' + + def perform(site_id:, once: true) + @site = Site.find site_id hostnames.each do |hostname| uris.each do |uri| - break if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop') + return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop') - AccessLog.where(host: hostname, uri: uri).completed_requests.non_robots.group(:host, :uri).rollup('host|uri', interval: 'day') + AccessLog.where(host: hostname, uri: uri) + .where('created_at >= ?', beginning_of_interval) + .completed_requests + .non_robots + .group(:host, :uri) + .rollup('host|uri', interval: starting_interval, update: true) + + # Reducir las estadísticas calculadas aplicando un rollup sobre el + # intervalo más amplio. + Stat::INTERVALS.reduce do |previous, current| + Rollup.where(name: 'host|uri', interval: previous) + .where_dimensions(host: hostname, uri: uri) + .group("dimensions->'host'", "dimensions->'uri'") + .rollup('host|uri', interval: current, update: true) do |rollup| + rollup.sum(:value) + end + + # Devolver el intervalo actual + current + end + end + end + + # Recordar la última vez que se corrió la tarea + site.stats.create! name: STAT_NAME + + run_again! unless once + end + + private + + def stat_name + STAT_NAME + end + + # @return [String] + # + # TODO: Cambiar al mergear origin-referer + def destination + @destination ||= site.deploys.find_by(type: 'DeployLocal').destination + end + + # TODO: Cambiar al mergear origin-referer + # + # @return [Array] + def hostnames + @hostnames ||= site.deploys.map do |deploy| + case deploy + when DeployLocal + site.hostname + when DeployWww + deploy.fqdn + when DeployAlternativeDomain + deploy.hostname.dup.tap do |h| + h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}") + end + when DeployHiddenService + deploy.onion + end + end.compact + end + + # Recolecta todas las URIs menos imágenes + # + # @return [Array] + def uris + @uris ||= Dir.chdir destination do + (Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p| + File.directory? p + end.reject do |p| + p = p.downcase + + IMAGES.any? do |i| + p.end_with? i + end + end).map do |uri| + "/#{uri}" end end end diff --git a/app/models/site.rb b/app/models/site.rb index ddfe2bc9..df92264a 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -37,6 +37,7 @@ class Site < ApplicationRecord belongs_to :design belongs_to :licencia + has_many :stats has_many :log_entries, dependent: :destroy has_many :deploys, dependent: :destroy has_many :build_stats, through: :deploys diff --git a/app/models/stat.rb b/app/models/stat.rb index c986ba4b..5f72ccd0 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +# Registran cuándo fue la última recolección de datos. class Stat < ApplicationRecord - INTERVALS = %i[year month day].freeze + # XXX: Los intervalos van en orden de mayor especificidad a menor + INTERVALS = %i[day month year].freeze RESOURCES = %i[builds space_used build_time].freeze + + belongs_to :site end