Merge pull request #12998 from opf/task/48717-replace-delayedjob

[#48717] Replace DelayedJob with GoodJob.
This commit is contained in:
Markus Kahl
2024-03-06 12:47:01 +00:00
committed by GitHub
94 changed files with 1146 additions and 1231 deletions
+1 -2
View File
@@ -124,8 +124,7 @@ gem 'multi_json', '~> 1.15.0'
gem 'oj', '~> 3.16.0'
gem 'daemons'
gem 'delayed_cron_job', '~> 0.9.0'
gem 'delayed_job_active_record', '~> 4.1.5'
gem 'good_job', '~> 3.26.1' # update should be done manually in sync with saas-openproject version.
gem 'rack-protection', '~> 3.2.0'
+9 -10
View File
@@ -446,13 +446,6 @@ GEM
deckar01-task_list (2.3.4)
html-pipeline (~> 2.0)
declarative (0.0.20)
delayed_cron_job (0.9.0)
fugit (>= 1.5)
delayed_job (4.1.11)
activesupport (>= 3.0, < 8.0)
delayed_job_active_record (4.1.8)
activerecord (>= 3.0, < 8.0)
delayed_job (>= 3.0, < 5)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
diff-lcs (1.5.1)
@@ -551,7 +544,7 @@ GEM
friendly_id (5.5.1)
activerecord (>= 4.0.0)
front_matter_parser (1.0.1)
fugit (1.9.0)
fugit (1.10.1)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
fuubar (2.5.1)
@@ -565,6 +558,13 @@ GEM
i18n (>= 0.7)
multi_json
request_store (>= 1.0)
good_job (3.26.1)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
google-apis-core (0.14.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
@@ -1164,8 +1164,6 @@ DEPENDENCIES
date_validator (~> 0.12.0)
debug
deckar01-task_list (~> 2.3.1)
delayed_cron_job (~> 0.9.0)
delayed_job_active_record (~> 4.1.5)
disposable (~> 0.6.2)
doorkeeper (~> 5.6.6)
dotenv-rails
@@ -1183,6 +1181,7 @@ DEPENDENCIES
friendly_id (~> 5.5.0)
fuubar (~> 2.5.0)
gon (~> 6.4.0)
good_job (~> 3.26.1)
google-apis-gmail_v1
googleauth
grape (~> 2.0.0)
+1 -1
View File
@@ -1,3 +1,3 @@
web: bundle exec rails server -p 3000 -b ${HOST:="127.0.0.1"} --environment ${RAILS_ENV:="development"}
angular: npm run serve
worker: bundle exec rake jobs:work
worker: bundle exec good_job start
@@ -42,7 +42,9 @@ module Settings
end
def unique_job
if WorkPackages::ApplyWorkingDaysChangeJob.scheduled?
if GoodJob::Job
.where(finished_at: nil)
.exists?(job_class: WorkPackages::ApplyWorkingDaysChangeJob.name)
errors.add :base, :previous_working_day_changes_unprocessed
end
end
+3 -3
View File
@@ -126,13 +126,13 @@ class RootSeeder < Seeder
ActionMailer::Base.perform_deliveries = false
# Avoid asynchronous DeliverWorkPackageCreatedJob
previous_delay_jobs = Delayed::Worker.delay_jobs
Delayed::Worker.delay_jobs = false
previous_execution_mode = Rails.configuration.good_job.execution_mode
Rails.configuration.good_job.execution_mode = :inline
yield
ensure
ActionMailer::Base.perform_deliveries = previous_perform_deliveries
Delayed::Worker.delay_jobs = previous_delay_jobs
Rails.configuration.good_job.execution_mode = previous_execution_mode
end
def seed_basic_data
+4
View File
@@ -92,6 +92,10 @@ class ApplicationJob < ActiveJob::Base
Setting.reload_mailer_settings!
end
def job_scheduled_at
GoodJob::Job.where(id: job_id).pick(:scheduled_at)
end
private
def prepare_job_context
@@ -26,12 +26,9 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Attachments::CleanupUncontaineredJob < Cron::CronJob
class Attachments::CleanupUncontaineredJob < ApplicationJob
queue_with_priority :low
# runs at 10:03 pm
self.cron_expression = '03 22 * * *'
def perform
Attachment
.where(container: nil)
@@ -50,7 +47,7 @@ class Attachments::CleanupUncontaineredJob < Cron::CronJob
attachment_table = Attachment.arel_table
attachment_table[:created_at]
.lteq(Time.now - OpenProject::Configuration.attachments_grace_period.minutes)
.lteq(Time.zone.now - OpenProject::Configuration.attachments_grace_period.minutes)
.to_sql
end
end
-41
View File
@@ -1,41 +0,0 @@
# OpenProject is an open source project management software.
# Copyright (C) 2010-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
module ScheduledJob
extend ActiveSupport::Concern
class_methods do
##
# Is there a job scheduled?
def scheduled?
delayed_job_query.exists?
end
def delayed_job_query
Delayed::Job.where('handler LIKE ?', "%job_class: #{name}%")
end
end
end
+1 -4
View File
@@ -27,12 +27,9 @@
#++
module Cron
class ClearOldSessionsJob < CronJob
class ClearOldSessionsJob < ApplicationJob
include ::RakeJob
# runs at 1:15 nightly
self.cron_expression = '15 1 * * *'
def perform
super('db:sessions:expire', 7)
end
+1 -4
View File
@@ -27,12 +27,9 @@
#++
module Cron
class ClearTmpCacheJob < CronJob
class ClearTmpCacheJob < ApplicationJob
include ::RakeJob
# runs at 02:45 sundays
self.cron_expression = '45 2 * * 7'
def perform
super('tmp:cache:clear')
end
+1 -4
View File
@@ -27,12 +27,9 @@
#++
module Cron
class ClearUploadedFilesJob < CronJob
class ClearUploadedFilesJob < ApplicationJob
include ::RakeJob
# Runs 23pm fridays
self.cron_expression = '0 23 * * 5'
def perform
super('attachments:clear')
end
+1 -4
View File
@@ -27,10 +27,7 @@
#++
module Ldap
class SynchronizationJob < ::Cron::CronJob
# Run once per night at 11:30pm
self.cron_expression = '30 23 * * *'
class SynchronizationJob < ApplicationJob
def perform
run_user_sync
end
@@ -28,15 +28,11 @@
module Notifications
# Creates date alert jobs for users whose local time is 1:00 am.
class ScheduleDateAlertsNotificationsJob < Cron::CronJob
# runs every quarter of an hour, so 00:00, 00:15,..., 15:30, 15:45, 16:00, ...
self.cron_expression = '*/15 * * * *'
class ScheduleDateAlertsNotificationsJob < ApplicationJob
def perform
return unless EnterpriseToken.allows_to?(:date_alerts)
service = Service.new(times_from_scheduled_to_execution)
service.call
Service.new(times_from_scheduled_to_execution).call
end
# Returns times from scheduled execution time to current time in 15 minutes
@@ -57,7 +53,7 @@ module Notifications
end
def scheduled_time
self.class.delayed_job.run_at.then { |t| t.change(min: t.min / 15 * 15) }
job_scheduled_at.then { |t| t.change(min: t.min / 15 * 15) }
end
end
end
@@ -27,18 +27,13 @@
#++
module Notifications
class ScheduleReminderMailsJob < Cron::CronJob
# runs every quarter of an hour, so 00:00, 00:15...
self.cron_expression = '*/15 * * * *'
class ScheduleReminderMailsJob < ApplicationJob
def perform
User.having_reminder_mail_to_send(run_at).pluck(:id).each do |user_id|
User.having_reminder_mail_to_send(job_scheduled_at)
.pluck(:id)
.each do |user_id|
Mails::ReminderJob.perform_later(user_id)
end
end
def run_at
self.class.delayed_job.run_at
end
end
end
+1 -4
View File
@@ -27,12 +27,9 @@
#++
module OAuth
class CleanupJob < ::Cron::CronJob
class CleanupJob < ApplicationJob
include ::RakeJob
# runs at 1:52 nightly
self.cron_expression = '52 1 * * *'
queue_with_priority :low
def perform
@@ -27,10 +27,7 @@
#++
module PaperTrailAudits
class CleanupJob < ::Cron::CronJob
# runs at 4:03 on Saturday
self.cron_expression = '3 4 * * 6'
class CleanupJob < ApplicationJob
# Clean any paper trails older than 60 days
def perform
::PaperTrailAudit
@@ -1,73 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Projects
class ReorderHierarchyJob < ApplicationJob
def perform
Rails.logger.info { "Resorting siblings by name in the project's nested set." }
Project.transaction { reorder! }
end
private
def reorder!
# Reorder the project roots
reorder_siblings Project.roots
# Reorder every project hierarchy
Project
.where(id: unique_parent_ids)
.find_each { |project| reorder_siblings(project.children) }
end
def unique_parent_ids
Project
.where.not(parent_id: nil)
.select(:parent_id)
.distinct
end
def reorder_siblings(siblings)
return unless siblings.many?
# Resort children manually
sorted = siblings.sort_by { |project| project.name.downcase }
# Get the current first child
first = siblings.first
sorted.each_with_index do |child, i|
if i == 0
child.move_to_left_of(first) unless child == first
else
child.move_to_right_of(sorted[i - 1])
end
end
end
end
end
@@ -28,7 +28,6 @@
class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
queue_with_priority :above_normal
include ::ScheduledJob
def perform(user_id:, previous_working_days:, previous_non_working_days:)
user = User.find(user_id)
+2 -2
View File
@@ -13,7 +13,7 @@ FAIL_COUNT_FILE=/tmp/.liveness-check-fail-count.op
#
# This checks across all workers and can't tell if it's only this worker in particular
# that doesn't seem to be doing anything.
UNPROCESSED_COUNT=`psql -d ${DATABASE_URL%%\?*} -c "select count(*) from delayed_jobs where locked_by is null and run_at < (now() - interval '15 minutes');" | tail -n +3 | head -n1 | tr -d ' '`
UNPROCESSED_COUNT=`psql -d ${DATABASE_URL%%\?*} -c "select count(*) from good_jobs where finished_at is null and scheduled_at < (now() - interval '15 minutes');" | tail -n +3 | head -n1 | tr -d ' '`
echo "unprocessed: $UNPROCESSED_COUNT"
@@ -25,7 +25,7 @@ if [ "$UNPROCESSED_COUNT" = "0" ]; then
exit 0
else
MIN_RUN_AT=`psql -d ${DATABASE_URL%%\?*} -c "select run_at from delayed_jobs where locked_by is null and cron is null and run_at is not null and run_at < (now() - interval '1 minutes') order by run_at asc limit 1;" | tail -n +3 | head -n1`
MIN_RUN_AT=`psql -d ${DATABASE_URL%%\?*} -c "select scheduled_at from good_jobs where finished_at is null and cron_key is null and scheduled_at is not null and scheduled_at < (now() - interval '1 minutes') order by scheduled_at asc limit 1;" | tail -n +3 | head -n1`
LAST_MIN_RUN_AT=`cat $MIN_RUN_AT_FILE 2>/dev/null || echo 0`
NUM_FAILS=`cat $FAIL_COUNT_FILE 2>/dev/null || echo 0`
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
if ps aux | grep 'rake jobs:work' | grep -v grep; then
if ps aux | grep 'good_job start' | grep -v grep; then
echo "background worker running"
exit 0
fi
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env ruby
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment'))
require 'delayed/command'
Delayed::Command.new(ARGV).daemonize
+1 -1
View File
@@ -30,7 +30,7 @@ echo "---------------------------------------"
echo "Done. Now start the following services"
echo '- Rails server `RAILS_ENV=development bin/rails server`'
echo '- Angular CLI: `npm run serve`'
echo '- Delayed Job worker: `RAILS_ENV=development bin/rails jobs:work`'
echo '- Good Job worker: `RAILS_ENV=development bundle exec good_job start`'
echo ""
echo 'You can also run `bin/dev` to run all the above on a single terminal.'
echo ""
+14 -1
View File
@@ -212,7 +212,20 @@ module OpenProject
# This allows for setting the root either via config file or via environment variable.
config.action_controller.relative_url_root = OpenProject::Configuration['rails_relative_url_root']
config.active_job.queue_adapter = :delayed_job
config.active_job.queue_adapter = :good_job
config.good_job.retry_on_unhandled_error = false
# It has been commented out because AppSignal gem modifies ActiveJob::Base to report exceptions already.
# config.good_job.on_thread_error = -> (exception) { OpenProject.logger.error(exception) }
config.good_job.execution_mode = :external
config.good_job.preserve_job_records = true
config.good_job.cleanup_preserved_jobs_before_seconds_ago = OpenProject::Configuration[:good_job_cleanup_preserved_jobs_before_seconds_ago]
config.good_job.queues = OpenProject::Configuration[:good_job_queues]
config.good_job.max_threads = OpenProject::Configuration[:good_job_max_threads]
config.good_job.max_cache = OpenProject::Configuration[:good_job_max_cache]
config.good_job.enable_cron = OpenProject::Configuration[:good_job_enable_cron]
config.good_job.shutdown_timeout = 30
config.good_job.smaller_number_is_higher_priority = false
config.action_controller.asset_host = OpenProject::Configuration::AssetHost.value
+30 -7
View File
@@ -483,6 +483,36 @@ module Settings
description: 'Forced page size for manually sorted work package views',
default: 250
},
good_job_queues: {
description: '',
format: :string,
writable: false,
default: '*'
},
good_job_max_threads: {
description: '',
format: :integer,
writable: false,
default: 20
},
good_job_max_cache: {
description: '',
format: :integer,
writable: false,
default: 10_000
},
good_job_enable_cron: {
description: '',
format: :boolean,
writable: false,
default: true
},
good_job_cleanup_preserved_jobs_before_seconds_ago: {
description: '',
format: :integer,
writable: false,
default: 7.days
},
host_name: {
default: "localhost:3000"
},
@@ -492,13 +522,6 @@ module Settings
format: :string,
default: nil
},
# Maximum number of backed up jobs (that are not yet executed)
# before health check fails
health_checks_jobs_queue_count_threshold: {
description: 'Set threshold of backed up background jobs to fail health check',
format: :integer,
default: 50
},
## Maximum number of minutes that jobs have not yet run after their designated 'run_at' time
health_checks_jobs_never_ran_minutes_ago: {
description: 'Set threshold of outstanding background jobs to fail health check',
+69 -13
View File
@@ -1,15 +1,71 @@
# Register "Cron-like jobs"
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
Rails.application.configure do |application|
application.config.to_prepare do
Cron::CronJob.register! Cron::ClearOldSessionsJob,
Cron::ClearTmpCacheJob,
Cron::ClearUploadedFilesJob,
OAuth::CleanupJob,
PaperTrailAudits::CleanupJob,
Attachments::CleanupUncontaineredJob,
Notifications::ScheduleDateAlertsNotificationsJob,
Notifications::ScheduleReminderMailsJob,
Ldap::SynchronizationJob
end
# Register "Cron-like jobs"
Rails.application.config.after_initialize do
Rails.application.config.good_job.cron.merge!(
{
'Cron::ClearOldSessionsJob': {
cron: '15 1 * * *', # runs at 1:15 nightly
class: Cron::ClearOldSessionsJob.name
},
'Cron::ClearTmpCacheJob': {
cron: '45 2 * * 7', # runs at 02:45 sundays
class: Cron::ClearTmpCacheJob.name
},
'Cron::ClearUploadedFilesJob': {
cron: '0 23 * * 5', # runs 23:00 fridays
class: Cron::ClearUploadedFilesJob.name
},
'OAuth::CleanupJob': {
cron: '52 1 * * *',
class: OAuth::CleanupJob.name
},
'PaperTrailAudits::CleanupJob': {
cron: '3 4 * * 6',
class: PaperTrailAudits::CleanupJob.name
},
'Attachments::CleanupUncontaineredJob': {
cron: '03 22 * * *',
class: Attachments::CleanupUncontaineredJob.name
},
'Notifications::ScheduleDateAlertsNotificationsJob': {
cron: '*/15 * * * *',
class: Notifications::ScheduleDateAlertsNotificationsJob.name
},
'Notifications::ScheduleReminderMailsJob': {
cron: '*/15 * * * *',
class: Notifications::ScheduleReminderMailsJob.name
},
'Ldap::SynchronizationJob': {
cron: '30 23 * * *',
class: Ldap::SynchronizationJob.name
}
}
)
end
+28
View File
@@ -1,3 +1,31 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
config = Rails.env.production? && Rails.application.config.database_configuration[Rails.env]
pool_size = config && [OpenProject::Configuration.web_max_threads + 1, config['pool'].to_i].max
-55
View File
@@ -1,55 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
# Disable delayed_job's own logging as we have activejob
Delayed::Worker.logger = nil
# By default bypass worker queue and execute asynchronous tasks at once
Delayed::Worker.delay_jobs = true
# Prevent loading ApplicationJob during initialization
Rails.application.reloader.to_prepare do
# Set default priority (lower = higher priority)
# Example ordering, see ApplicationJob.priority_number
Delayed::Worker.default_priority = ApplicationJob.priority_number(:default)
end
# Do not retry jobs from delayed_job
# instead use 'retry_on' activejob functionality
Delayed::Worker.max_attempts = 1
# Remember DJ id in the payload object
class Delayed::ProviderJobIdPlugin < Delayed::Plugin
callbacks do |lifecycle|
lifecycle.before(:invoke_job) do |job|
job.payload_object.job_data['provider_job_id'] = job.id if job.payload_object.respond_to?(:job_data)
end
end
end
Delayed::Worker.plugins << Delayed::ProviderJobIdPlugin
+29 -99
View File
@@ -1,107 +1,37 @@
require 'ok_computer/ok_computer_controller'
class DelayedJobNeverRanCheck < OkComputer::Check
attr_reader :threshold
Rails.application.configure do
config.after_initialize do
OkComputer::Registry.register "worker", OpenProject::HealthChecks::GoodJobCheck.new
OkComputer::Registry.register "worker_backed_up", OpenProject::HealthChecks::GoodJobBackedUpCheck.new
def initialize(minute_threshold)
@threshold = minute_threshold.to_i
end
OkComputer::Registry.register "puma", OpenProject::HealthChecks::PumaCheck.new
def check
never_ran = Delayed::Job.where('run_at < ?', threshold.minutes.ago).count
# Make dj backed up optional due to bursts
OkComputer.make_optional %w(worker_backed_up puma)
if never_ran.zero?
mark_message "All previous jobs have completed within the past #{threshold} minutes."
else
mark_failure
mark_message "#{never_ran} jobs waiting to be executed for more than #{threshold} minutes"
# Register web worker check for web + database
OkComputer::CheckCollection.new('web').tap do |collection|
collection.register :default, OkComputer::Registry.fetch('default')
collection.register :database, OkComputer::Registry.fetch('database')
OkComputer::Registry.default_collection.register 'web', collection
end
# Register full check for web + database + dj worker
OkComputer::CheckCollection.new('full').tap do |collection|
collection.register :default, OkComputer::Registry.fetch('default')
collection.register :database, OkComputer::Registry.fetch('database')
collection.register :mail, OpenProject::HealthChecks::SmtpCheck.new
collection.register :worker, OkComputer::Registry.fetch('worker')
collection.register :worker_backed_up, OkComputer::Registry.fetch('worker_backed_up')
collection.register :puma, OkComputer::Registry.fetch('puma')
OkComputer::Registry.default_collection.register 'full', collection
end
# Check if authentication required
authentication_password = OpenProject::Configuration.health_checks_authentication_password
if authentication_password.present?
OkComputer.require_authentication('health_checks', authentication_password)
end
end
end
class PumaCheck < OkComputer::Check
attr_reader :threshold
def initialize(backlog_threshold)
@threshold = backlog_threshold.to_i
end
def check
stats = self.stats
return mark_message "N/A as Puma is not used." if stats.nil?
if stats[:running] > 0
mark_message "Puma is running"
else
mark_failure
mark_message "Puma is not running"
end
if stats[:backlog] < threshold
mark_message "Backlog ok"
else
mark_failure
mark_message "Backlog congested"
end
end
def stats
return nil unless applicable?
server = Puma::Server.current
return nil if server.nil?
{
backlog: server.backlog || 0,
running: server.running || 0,
pool_capacity: server.pool_capacity || 0,
max_threads: server.max_threads || 0
}
end
def applicable?
return @applicable unless @applicable.nil?
@applicable = Object.const_defined?("Puma::Server") && !Puma::Server.current.nil?
end
end
# Register delayed_job backed up test
dj_max = OpenProject::Configuration.health_checks_jobs_queue_count_threshold
OkComputer::Registry.register "delayed_jobs_backed_up",
OkComputer::DelayedJobBackedUpCheck.new(0, dj_max)
dj_never_ran_max = OpenProject::Configuration.health_checks_jobs_never_ran_minutes_ago
OkComputer::Registry.register "delayed_jobs_never_ran",
DelayedJobNeverRanCheck.new(dj_never_ran_max)
backlog_threshold = OpenProject::Configuration.health_checks_backlog_threshold
OkComputer::Registry.register "puma", PumaCheck.new(backlog_threshold)
# Make dj backed up optional due to bursts
OkComputer.make_optional %w(delayed_jobs_backed_up puma)
# Register web worker check for web + database
OkComputer::CheckCollection.new('web').tap do |collection|
collection.register :default, OkComputer::Registry.fetch('default')
collection.register :database, OkComputer::Registry.fetch('database')
OkComputer::Registry.default_collection.register 'web', collection
end
# Register full check for web + database + dj worker
OkComputer::CheckCollection.new('full').tap do |collection|
collection.register :default, OkComputer::Registry.fetch('default')
collection.register :database, OkComputer::Registry.fetch('database')
collection.register :mail, OkComputer::ActionMailerCheck.new
collection.register :delayed_jobs_backed_up, OkComputer::Registry.fetch('delayed_jobs_backed_up')
collection.register :delayed_jobs_never_ran, OkComputer::Registry.fetch('delayed_jobs_never_ran')
collection.register :puma, OkComputer::Registry.fetch('puma')
OkComputer::Registry.default_collection.register 'full', collection
end
# Check if authentication required
authentication_password = OpenProject::Configuration.health_checks_authentication_password
if authentication_password.present?
OkComputer.require_authentication('health_checks', authentication_password)
end
+4
View File
@@ -650,4 +650,8 @@ Rails.application.routes.draw do
if OpenProject::Configuration.lookbook_enabled?
mount Lookbook::Engine, at: "/lookbook"
end
if Rails.env.development?
mount GoodJob::Engine => 'good_job'
end
end
@@ -31,8 +31,6 @@ class RemoveRenamedCronJob < ActiveRecord::Migration[6.0]
# The job has been renamed to JobStatus::Cron::ClearOldJobStatusJob
# the new job will be added on restarting the application but the old will still be in the database
# and will cause 'uninitialized constant' errors.
Delayed::Job
.where('handler LIKE ?', "%job_class: Cron::ClearOldJobStatusJob%")
.delete_all
execute("DELETE FROM delayed_jobs WHERE handler LIKE '%job_class: Cron::ClearOldJobStatusJob%'")
end
end
@@ -27,7 +27,54 @@
#++
class ReorderProjectChildren < ActiveRecord::Migration[6.1]
class ProjectMigration < ApplicationRecord
include ::Projects::Hierarchy
self.table_name = 'projects'
end
def up
::Projects::ReorderHierarchyJob.perform_later
Rails.logger.info { "Resorting siblings by name in the project's nested set." }
ProjectMigration.transaction { reorder! }
end
def down
# Nothing to do
end
private
def reorder!
# Reorder the project roots
reorder_siblings ProjectMigration.roots
# Reorder every project hierarchy
ProjectMigration
.where(id: unique_parent_ids)
.find_each { |project| reorder_siblings(project.children) }
end
def unique_parent_ids
ProjectMigration
.where.not(parent_id: nil)
.select(:parent_id)
.distinct
end
def reorder_siblings(siblings)
return unless siblings.many?
# Resort children manually
sorted = siblings.sort_by { |project| project.name.downcase }
# Get the current first child
first = siblings.first
sorted.each_with_index do |child, i|
if i == 0
child.move_to_left_of(first) unless child == first
else
child.move_to_right_of(sorted[i - 1])
end
end
end
end
@@ -28,12 +28,7 @@
class RemoveNotificationCleanupJob < ActiveRecord::Migration[7.0]
def up
# Remove the cron job no longer desired.
# The code itself is removed but keeping it in the database would lead to UninitializedConstant errors.
Delayed::Job
.where('handler LIKE ?', "%job_class: Notifications::CleanupJob%")
.delete_all
execute("DELETE FROM delayed_jobs WHERE handler LIKE '%job_class: Notifications::CleanupJob%'")
Setting
.where(name: 'notification_retention_period_days')
.delete_all
@@ -60,8 +60,8 @@ class BigintPrimaryAndForeignKeys < ActiveRecord::Migration[7.0]
CustomStyle => [:id],
CustomValue => %i[id customized_id custom_field_id],
Journal::CustomizableJournal => %i[id journal_id custom_field_id],
Delayed::Job => [:id],
DesignColor => [:id],
:delayed_jobs => [:id], # delayed job removed in favour of good_job see WP #42 or PR #42
Journal::DocumentJournal => %i[id project_id category_id],
Document => %i[id project_id category_id],
:done_statuses_for_project => %i[project_id status_id],
@@ -30,8 +30,6 @@ class RemoveRenamedDateAlertJob < ActiveRecord::Migration[6.0]
def up
# The job has been renamed to Notifications::ScheduleDateAlertsNotificationsJob.
# The new job will be added on restarting the application.
Delayed::Job
.where('handler LIKE ?', "%job_class: Notifications::CreateDateAlertsNotificationsJob%")
.delete_all
execute("DELETE FROM delayed_jobs WHERE handler LIKE '%job_class: Notifications::CreateDateAlertsNotificationsJob%'")
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
class CreateGoodJobs < ActiveRecord::Migration[7.0]
def change
# Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
# enable_extension 'pgcrypto'
create_table :good_jobs, id: :uuid do |t|
t.text :queue_name
t.integer :priority
t.jsonb :serialized_params
t.datetime :scheduled_at
t.datetime :performed_at
t.datetime :finished_at
t.text :error
t.timestamps
t.uuid :active_job_id
t.text :concurrency_key
t.text :cron_key
t.uuid :retried_good_job_id
t.datetime :cron_at
end
create_table :good_job_processes, id: :uuid do |t|
t.timestamps
t.jsonb :state
end
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at
add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
add_index :good_jobs, [:cron_key, :created_at], name: :index_good_jobs_on_cron_key_and_created_at
add_index :good_jobs, [:cron_key, :cron_at], name: :index_good_jobs_on_cron_key_and_cron_at, unique: true
add_index :good_jobs, [:active_job_id], name: :index_good_jobs_on_active_job_id
add_index :good_jobs, [:finished_at], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at
end
end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class CreateGoodJobSettings < ActiveRecord::Migration[7.0]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.table_exists?(:good_job_settings)
end
end
create_table :good_job_settings, id: :uuid do |t|
t.timestamps
t.text :key
t.jsonb :value
t.index :key, unique: true
end
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.index_name_exists?(:good_jobs, :index_good_jobs_jobs_on_priority_created_at_when_unfinished)
end
end
add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished,
algorithm: :concurrently
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class CreateGoodJobBatches < ActiveRecord::Migration[7.0]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.table_exists?(:good_job_batches)
end
end
create_table :good_job_batches, id: :uuid do |t|
t.timestamps
t.text :description
t.jsonb :serialized_properties
t.text :on_finish
t.text :on_success
t.text :on_discard
t.text :callback_queue_name
t.integer :callback_priority
t.datetime :enqueued_at
t.datetime :discarded_at
t.datetime :finished_at
end
change_table :good_jobs do |t|
t.uuid :batch_id
t.uuid :batch_callback_id
t.index :batch_id, where: "batch_id IS NOT NULL"
t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL"
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class CreateGoodJobExecutions < ActiveRecord::Migration[7.0]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.table_exists?(:good_job_executions)
end
end
create_table :good_job_executions, id: :uuid do |t|
t.timestamps
t.uuid :active_job_id, null: false
t.text :job_class
t.text :queue_name
t.jsonb :serialized_params
t.datetime :scheduled_at
t.datetime :finished_at
t.text :error
t.index [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
end
change_table :good_jobs do |t|
t.boolean :is_discrete
t.integer :executions_count
t.text :job_class
end
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.0]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.column_exists?(:good_jobs, :error_event)
end
end
add_column :good_jobs, :error_event, :integer, limit: 2
add_column :good_job_executions, :error_event, :integer, limit: 2
end
end
@@ -0,0 +1,45 @@
# frozen_string_literal: true
class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
reversible do |dir|
dir.up do
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond)
add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)",
name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently
end
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond)
add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true,
name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently
end
if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at)
remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at
end
if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at)
remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at
end
end
dir.down do
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at)
add_index :good_jobs, [:cron_key, :created_at],
name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently
end
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at)
add_index :good_jobs, [:cron_key, :cron_at], unique: true,
name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently
end
if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond)
remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond
end
if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond)
remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond
end
end
end
end
end
@@ -0,0 +1,89 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class RemoveDelayedJobs < ActiveRecord::Migration[7.1]
# it is needed, because ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper
# can not be used without required delayed_job
# See https://github.com/rails/rails/blob/6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc/activejob/lib/active_job/queue_adapters/delayed_job_adapter.rb
class JobWrapperDeserializationMock
attr_accessor :job_data
def initialize(job_data)
@job_data = job_data
end
end
def change
reversible do |direction|
direction.up do
tuples = execute <<~SQL
select * from delayed_jobs
where locked_by is null -- not in progress
and handler NOT LIKE '%job_class: Storages::ManageNextcloudIntegrationEventsJob%' -- no need to migrate. It will be run later with cron.
and cron is null -- not cron schedule
FOR UPDATE; -- to prevent potentialy running delayed_job process working on these jobs(delayed_job uses SELECT FOR UPDATE to get workable jobs)
SQL
tuples.each do |tuple|
handler = tuple['handler'].gsub('ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper',
"ruby/object:#{RemoveDelayedJobs::JobWrapperDeserializationMock.name}")
job_data = YAML.load(handler, permitted_classes: [RemoveDelayedJobs::JobWrapperDeserializationMock])
.job_data
new_uuid = SecureRandom.uuid
good_job_record = GoodJob::BaseExecution.new
good_job_record.id = new_uuid
good_job_record.serialized_params = job_data
good_job_record.serialized_params['job_id'] = new_uuid
good_job_record.queue_name = job_data['queue_name']
good_job_record.priority = job_data['priority']
good_job_record.scheduled_at = job_data['scheduled_at']
good_job_record.active_job_id = new_uuid
good_job_record.concurrency_key = nil
good_job_record.job_class = job_data['job_class']
good_job_record.save!
end
end
direction.down {}
end
drop_table :delayed_jobs do |t|
t.integer :priority, default: 0
t.integer :attempts, default: 0
t.text :handler
t.text :last_error
t.datetime :run_at
t.datetime :locked_at
t.datetime :failed_at
t.string :locked_by
t.timestamps null: true
t.string :queue
t.string :cron
t.index %i[priority run_at], name: 'delayed_jobs_priority'
end
end
end
@@ -26,8 +26,8 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class ActiveSupport::TimeWithZone
def as_json(_options = {})
time.strftime('%m/%d/%Y/ %H:%M %p').to_s
class RenameDelayedJobStatuses < ActiveRecord::Migration[7.1]
def change
rename_table :delayed_job_statuses, :job_statuses
end
end
+1 -1
View File
@@ -68,7 +68,7 @@ services:
worker:
<<: *backend
command: bundle exec rake jobs:work
command: bundle exec good_job start
depends_on:
- db
- cache
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash -e
export QUEUE=bim,ifc_conversion
exec bundle exec rake jobs:work
exec bundle exec good_job start
+1 -1
View File
@@ -5,4 +5,4 @@ if [ "$1" = "--seed" ]; then
$APP_PATH/docker/prod/seeder "$@"
fi
QUIET=true bundle exec rake jobs:work
QUIET=true bundle exec good_job start
@@ -141,15 +141,8 @@ system's resources.
```shell
# Start the worker service and let it run continuously
docker compose up -d worker
# Start the worker service to work off all delayed jobs and shut it down afterwards
docker compose run --rm worker rake jobs:workoff
```
The testing containers are excluded as well, while they are harmless to start, but take up system resources again and
clog your logs while running. The delayed_job background worker reloads the application for every job in development
mode. This is a know issue and documented here: https://github.com/collectiveidea/delayed_job/issues/823
This process can take quite a long time on the first run where all gems are installed for the first time. However, these
are cached in a docker volume. Meaning that from the 2nd run onwards it will start a lot quicker.
@@ -250,7 +250,7 @@ You can then access the application either through `localhost:3000` (Rails serve
### Delayed Job background worker
```shell
RAILS_ENV=development bin/rails jobs:work
RAILS_ENV=development bundle exec good_job start
```
This will start a Delayed::Job worker to perform asynchronous jobs like sending emails.
@@ -298,12 +298,6 @@ brew install git
## Known issues
### Memory management
The delayed_job background worker reloads the application for every job in development mode. This is a know issue and documented here: https://github.com/collectiveidea/delayed_job/issues/823
### Spawning a lot of browser tabs
If you haven't run this command for a while, chances are that a lot of background jobs have queued up and might cause a significant amount of open tabs (due to the way we deliver mails with the letter_opener gem). To get rid of the jobs before starting the worker, use the following command. **This will remove all currently scheduled jobs, never use this in a production setting.**
@@ -301,7 +301,7 @@ You can then access the application either through `localhost:3000` (Rails serve
### Background job worker
```shell
RAILS_ENV=development bin/rails jobs:work
RAILS_ENV=development bundle exec good_job start
```
This will start a Delayed::Job worker to perform asynchronous jobs like sending emails.
@@ -310,12 +310,6 @@ This will start a Delayed::Job worker to perform asynchronous jobs like sending
## Known issues
### Memory management
The delayed_job background worker reloads the application for every job in development mode. This is a know issue and documented here: https://github.com/collectiveidea/delayed_job/issues/823
### Spawning a lot of browser tabs
If you haven't run this command for a while, chances are that a lot of background jobs have queued up and might cause a significant amount of open tabs (due to the way we deliver mails with the letter_opener gem). To get rid of the jobs before starting the worker, use the following command. **This will remove all currently scheduled jobs, never use this in a production setting.**
@@ -124,7 +124,7 @@ Set a higher number of web workers to allow more processes to be handled at the
There are two different types of emails in OpenProject: One sent directly within the request to the server (this includes the test mail) and one sent asynchronously, via a background job from the backend. The majority of mail sending jobs is run asynchronously to facilitate a faster response time for server request.
Use a browser to call your domain name followed by "health_checks/all" (e.g. `https://myopenproject.com/health_checks/all`). There should be entries about "delayed_jobs_backed_up" and "delayed_jobs_never_ran". If PASSED is written behind it, everything is good.
Use a browser to call your domain name followed by "health_checks/all" (e.g. `https://myopenproject.com/health_checks/all`). There should be entries about "worker" and "worker_backed_up". If PASSED is written behind it, everything is good.
If the health check does not return satisfying results, have a look if the background worker is running by entering `ps aux | grep jobs` on the server. If it is not running, no entry is returned. If it is running an entry with "jobs:work" at the end is displayed.
@@ -124,8 +124,10 @@ We provide the following health checks:
- `https://your-hostname.example.tld/health_checks/default` - An application level check to ensure the web workers are running.
- `https://your-hostname.example.tld/health_checks/database` - A database liveliness check.
- `https://your-hostname.example.tld/health_checks/delayed_jobs_never_ran` - A check to ensure background jobs are being processed.
- `https://your-hostname.example.tld/health_checks/delayed_jobs_backed_up` - A check to determine whether background workers are at capacity and might need to be scaled up to provide timely processing of mails and other background work.
- `https://your-hostname.example.tld/health_checks/mail` - SMTP configuration check.
- `https://your-hostname.example.tld/health_checks/puma` - A check on Puma web server.
- `https://your-hostname.example.tld/health_checks/worker` - A check to ensure background jobs are being processed.
- `https://your-hostname.example.tld/health_checks/worker_backed_up` - A check to determine whether background workers are at capacity and might need to be scaled up to provide timely processing of mails and other background work.
- `https://your-hostname.example.tld/health_checks/all` - All of the above checks and additional checks combined as one. Not recommended as the liveliness check of a pod/container.
### Optional authentication
@@ -348,9 +348,9 @@ You have setup the *Project folder* in both environments (Nextcloud and OpenProj
a. If you have root access to the OpenProject server where your worker should be running, check if the worker processes are in fact present:
`ps aux | grep job`
The result should show lines containing `bundle exec rake jobs:work`
The result should show lines containing `bundle exec bundle exec good_job start`
b. If you don't have root access to the OpenProject server then you can check the following URL in your browser: `https://<your-openproject-server>/health_checks/all` (please insert the domain name of your OpenProject server). If your background workers are running, you should see a line like that `delayed_jobs_never_ran: PASSED All previous jobs have completed within the past 5 minutes`
b. If you don't have root access to the OpenProject server then you can check the following URL in your browser: `https://<your-openproject-server>/health_checks/all` (please insert the domain name of your OpenProject server). If your background workers are running, you should see a line like that `worker_backed_up: PASSED No jobs are waiting to be picked up.`
2. Ensure that your project is setup correctly:
1. In your browser navigate to the project for which you want the **Project folders** feature to be working.
@@ -236,7 +236,7 @@ The following factors can have an impact on the duration of the export:
- Number of columns in the export (less of an impact)
To identify how many background jobs have run or are delayed, enter "/health_checks/full" after the URL (e.g. myopenprojectinstance.com/health_checks/full).
This provides an overview of "delayed_jobs_never_ran" which shows the number of background jobs that could not get ran within the last 10 minutes. If there are multiple entries, this can indicate that the number of web workers should be increased.
This provides an overview of "worker_backed_up" which shows the number of background jobs that could not get ran within the last 5 minutes. If there are multiple entries, this can indicate that the number of web workers should be increased.
For a documentation of how to do this, please refer to [these instructions](../../../installation-and-operations/operation/control) (see section "Scaling the number of web workers").
@@ -23,26 +23,26 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# See docs/COPYRIGHT.rdoc for more details.
#++
# This patch adds our job status extension to background jobs carried out when mailing with
# perform_later.
module OpenProject
module Patches
module DelayedJobAdapter
module AllowNonExistingJobClass
def log_arguments?
super
rescue NameError
false
module HealthChecks
class GoodJobBackedUpCheck < OkComputer::Check
def initialize(threshold = OpenProject::Configuration.health_checks_jobs_never_ran_minutes_ago)
@threshold = threshold.to_i
super()
end
def check
count = GoodJob::Job.queued.where('scheduled_at < ?', @threshold.minutes.ago).count
if count > 0
mark_message "#{count} jobs are waiting to be picked up for more than #{@threshold} minutes."
mark_failure
else
mark_message "No jobs are waiting to be picked up."
end
end
end
end
end
ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper.prepend(
OpenProject::Patches::DelayedJobAdapter::AllowNonExistingJobClass
)
@@ -23,18 +23,21 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# See docs/COPYRIGHT.rdoc for more details.
#++
# This patch adds our job status extension to background jobs carried out when mailing with
# perform_later.
module OpenProject
module Patches
module DeliveryJob
# include ::JobStatus::ApplicationJobWithStatus
module HealthChecks
class GoodJobCheck < OkComputer::Check
def check
count = GoodJob::Process.active.count
if count.zero?
mark_failure
mark_message "No good_job processes are active."
else
mark_message "#{count} good_job processes are active."
end
end
end
end
end
ActionMailer::MailDeliveryJob.include JobStatus::ApplicationJobWithStatus
@@ -23,53 +23,55 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# See docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject
module HealthChecks
class PumaCheck < OkComputer::Check
attr_reader :threshold
module Cron
class CronJob < ApplicationJob
class_attribute :cron_expression
def initialize(threshold = OpenProject::Configuration.health_checks_backlog_threshold)
@threshold = threshold.to_i
@applicable = Object.const_defined?("Puma::Server") && !Puma::Server.current.nil?
super()
end
# List of registered jobs, requires eager load in dev(!)
class_attribute :registered_jobs, default: []
def check
stats = self.stats
include ScheduledJob
return mark_message "N/A as Puma is not used." if stats.nil?
class << self
##
# Register new job class(es)
def register!(*job_classes)
Array(job_classes).each do |clz|
raise ArgumentError, "Needs to be subclass of ::Cron::CronJob" unless clz.ancestors.include?(self)
if stats[:running] > 0
mark_message "Puma is running"
else
mark_failure
mark_message "Puma is not running"
end
registered_jobs << clz
if stats[:backlog] < threshold
mark_message "Backlog ok"
else
mark_failure
mark_message "Backlog congested"
end
end
def schedule_registered_jobs!
registered_jobs.each do |job_class|
job_class.ensure_scheduled!
end
def stats
return nil unless applicable?
server = Puma::Server.current
return nil if server.nil?
{
backlog: server.backlog || 0,
running: server.running || 0,
pool_capacity: server.pool_capacity || 0,
max_threads: server.max_threads || 0
}
end
##
# Ensure the job is scheduled unless it is already
def ensure_scheduled!
# Ensure scheduled only once
return if scheduled?
Rails.logger.info { "Scheduling #{name} recurrent background job." }
set(cron: cron_expression).perform_later
end
##
# Remove the scheduled job, if any
def remove
delayed_job&.destroy
end
def delayed_job
delayed_job_query.first
def applicable?
!!@applicable
end
end
end
@@ -1,5 +1,3 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
@@ -25,22 +23,22 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# See docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject
module HealthChecks
class SmtpCheck < OkComputer::ActionMailerCheck
def check
unless Setting.email_delivery_method == :smtp
mark_message "Mail delivery method is not SMTP"
return
end
module Storages
class ManageNextcloudIntegrationCronJob < Cron::CronJob
include ManageNextcloudIntegrationJobMixin
# settings might change between calls
@host = Setting.smtp_address
@port = Setting.smtp_port
queue_with_priority :low
self.cron_expression = '1 * * * *'
def self.ensure_scheduled!
if ::Storages::ProjectStorage.active_automatically_managed.exists?
super
else
remove
end
end
end
@@ -293,13 +293,9 @@ module OpenProject::Plugins
OpenProject::Activity.register(event_type, options)
end
##
# Register a "cron"-like background job
def add_cron_jobs
def add_cron_jobs(&block)
config.to_prepare do
Array(yield).each do |clz|
::Cron::CronJob.register!(clz.is_a?(Class) ? clz : clz.to_s.constantize)
end
Rails.application.config.good_job.cron.merge!(block.call)
end
end
+2 -9
View File
@@ -27,15 +27,8 @@
#++
namespace 'openproject:cron' do
desc 'An hourly cron job hook for plugin functionality'
task :hourly do
# Does nothing by default
end
# This task will be automatically called when running jobs:work or jobs:workoff
# making sure cron jobs are scheduled. See lib/tasks/delayed_job.rake.
desc 'Ensure the cron-like background jobs are actively scheduled'
desc 'Ensure the cron-like background jobs are properly unscheduled if needed'
task schedule: [:environment] do
Cron::CronJob.schedule_registered_jobs!
Storages::ManageNextcloudIntegrationJob.disable_cron_job_if_needed
end
end
-43
View File
@@ -1,43 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
##
# Enhance the delayed_job prerequisites rake task to load the environment
unless Rake::Task.task_defined?('jobs:environment_options') &&
Rake::Task['jobs:work'].prerequisites == %w(environment_options)
raise "Trying to load the full environment for delayed_job, but jobs:work seems to have changed."
end
Rake::Task['jobs:environment_options']
.clear_prerequisites
.enhance(['environment:full'])
# Enhance delayed job workers to use cron
load 'lib/tasks/cron.rake'
Rake::Task["jobs:work"].enhance [:'openproject:cron:schedule']
Rake::Task["jobs:workoff"].enhance [:'openproject:cron:schedule']
@@ -27,12 +27,9 @@
#++
module Cron
class ClearOldPullRequestsJob < CronJob
class ClearOldPullRequestsJob < ApplicationJob
priority_number :low
# runs at 1:25 nightly
self.cron_expression = '25 1 * * *'
def perform
GithubPullRequest.without_work_package
.find_each(&:destroy!)
@@ -85,9 +85,13 @@ module OpenProject::GithubIntegration
mount ::API::V3::GithubPullRequests::GithubPullRequestsAPI
end
config.to_prepare do
# Register the cron job to clean up old github pull requests
::Cron::CronJob.register! ::Cron::ClearOldPullRequestsJob
add_cron_jobs do
{
'Cron::ClearOldPullRequestsJob': {
cron: '25 1 * * *', # runs at 1:25 nightly
class: ::Cron::ClearOldPullRequestsJob.name
}
}
end
end
end
@@ -1,6 +1,34 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module JobStatus
class Status < ApplicationRecord
self.table_name = 'delayed_job_statuses'
self.table_name = 'job_statuses'
belongs_to :user
belongs_to :reference, polymorphic: true
@@ -27,8 +27,7 @@
#++
module JobStatus
module ApplicationJobWithStatus
# Delayed jobs can have a status:
# Delayed::Job::Status
# Backgroun jobs can have a status JobStatus::Status
# which is related to the job via a reference which is an AR model instance.
def status_reference
nil
@@ -61,8 +60,6 @@ module JobStatus
##
# Update the status code for a given job
def upsert_status(status:, **args)
# Can't use upsert, as we only want to insert the user_id once
# and not update it repeatedly
resource = ::JobStatus::Status.find_or_initialize_by(job_id:)
if resource.new_record?
@@ -77,7 +74,16 @@ module JobStatus
resource.attributes = build_status_attributes(args.merge(status:))
end
# There is a possible race condition because of unique job_statuses.job_id
# Can't use upsert easily, because before updating we need to know user_id
# to set proper locale. Probably, it is possible to get it from
# a job's payload, then it would be possible to correctly prepare attributes before using upsert.
# Therefore, it is up to possible optimization in future. Now the race condition is handled with
# handling ActiveRecord::RecordNotUnique and trying again.
resource.save!
rescue ActiveRecord::RecordNotUnique
OpenProject.logger.info("Retrying ApplicationJobWithStatus#upsert_status.")
retry
end
protected
@@ -28,15 +28,12 @@
module JobStatus
module Cron
class ClearOldJobStatusJob < ::Cron::CronJob
# runs at 4:15 nightly
self.cron_expression = '15 4 * * *'
class ClearOldJobStatusJob < ApplicationJob
RETENTION_PERIOD = 2.days.freeze
def perform
::JobStatus::Status
.where(::JobStatus::Status.arel_table[:updated_at].lteq(Time.now - RETENTION_PERIOD))
.where(::JobStatus::Status.arel_table[:updated_at].lteq(Time.zone.now - RETENTION_PERIOD))
.destroy_all
end
end
@@ -46,10 +46,16 @@ module OpenProject::JobStatus
"#{root}/job_statuses/#{uuid}"
end
config.to_prepare do
# Register the cron job to clear statuses periodically
::Cron::CronJob.register! ::JobStatus::Cron::ClearOldJobStatusJob
add_cron_jobs do
{
'JobStatus::Cron::ClearOldJobStatusJob': {
cron: '15 4 * * *', # runs at 4:15 nightly
class: ::JobStatus::Cron::ClearOldJobStatusJob.name
}
}
end
config.to_prepare do
# Extends the ActiveJob adapter in use (DelayedJob) by a Status which lives
# indenpendently from the job itself (which is deleted once successful or after max attempts).
# That way, the result of a background job is available even after the original job is gone.
@@ -27,10 +27,7 @@
#++
module LdapGroups
class SynchronizationJob < ::Cron::CronJob
# Run every 30 minutes
self.cron_expression = '*/30 * * * *'
class SynchronizationJob < ApplicationJob
def perform
return unless EnterpriseToken.allows_to?(:ldap_groups)
return if skipped?
@@ -19,7 +19,14 @@ module OpenProject::LdapGroups
enterprise_feature: 'ldap_groups'
end
add_cron_jobs { LdapGroups::SynchronizationJob }
add_cron_jobs do
{
'Ldap::SynchronizationJob': {
cron: '30 23 * * *', # Run once per night at 11:30pm
class: Ldap::SynchronizationJob.name
}
}
end
patches %i[LdapAuthSource Group User]
end
@@ -26,15 +26,13 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Storages::CleanupUncontaineredFileLinksJob < Cron::CronJob
class Storages::CleanupUncontaineredFileLinksJob < ApplicationJob
queue_with_priority :low
self.cron_expression = '06 22 * * *'
def perform
Storages::FileLink
.where(container: nil)
.where('created_at <= ?', Time.current - OpenProject::Configuration.attachments_grace_period.minutes)
.where("created_at <= ?", Time.current - OpenProject::Configuration.attachments_grace_period.minutes)
.delete_all
end
end
@@ -1,63 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages
class ManageNextcloudIntegrationEventsJob < ApplicationJob
include ManageNextcloudIntegrationJobMixin
SINGLE_THREAD_DEBOUNCE_TIME = 4.seconds.freeze
MULTI_THREAD_DEBOUNCE_TIME = 5.seconds.freeze
KEY = :manage_nextcloud_integration_events_job_debounce_happend_at
queue_with_priority :above_normal
class << self
def debounce
unless debounce_happend_in_current_thread_recently?
Rails.cache.fetch(KEY, expires_in: MULTI_THREAD_DEBOUNCE_TIME) do
set(wait: MULTI_THREAD_DEBOUNCE_TIME).perform_later
RequestStore.store[KEY] = Time.current
end
end
end
private
def debounce_happend_in_current_thread_recently?
timestamp = RequestStore.store[KEY]
timestamp.present? && (timestamp + SINGLE_THREAD_DEBOUNCE_TIME) > Time.current
end
end
def perform
lock_obtained = super
self.class.debounce unless lock_obtained
lock_obtained
end
end
end
@@ -0,0 +1,108 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages
class ManageNextcloudIntegrationJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency
using ::Storages::Peripherals::ServiceResultRefinements
good_job_control_concurrency_with(
total_limit: 2,
enqueue_limit: 1,
perform_limit: 1,
key: "ManageNextcloudIntegrationJob"
)
SINGLE_THREAD_DEBOUNCE_TIME = 4.seconds.freeze
KEY = :manage_nextcloud_integration_job_debounce_happend_at
CRON_JOB_KEY = :'Storages::ManageNextcloudIntegrationJob'
queue_with_priority :above_normal
class << self
def debounce
if debounce_happend_in_current_thread_recently?
false
else
# TODO:
# Why there is 5 seconds delay?
# it is like that because for 1 thread and if there is no delay more than
# SINGLE_THREAD_DEBOUNCE_TIME(4.seconds)
# then some events can be lost
#
# Possibly "true" solutions are:
# 1. have after_request middleware to schedule one job after a request cycle
# 2. use concurrent ruby to have 'true' debounce.
result = set(wait: 5.seconds).perform_later
RequestStore.store[KEY] = Time.current
result
end
end
def disable_cron_job_if_needed
if ::Storages::ProjectStorage.active_automatically_managed.exists?
GoodJob::Setting.cron_key_enable(CRON_JOB_KEY) unless GoodJob::Setting.cron_key_enabled?(CRON_JOB_KEY)
elsif GoodJob::Setting.cron_key_enabled?(CRON_JOB_KEY)
GoodJob::Setting.cron_key_disable(CRON_JOB_KEY)
end
end
private
def debounce_happend_in_current_thread_recently?
timestamp = RequestStore.store[KEY]
timestamp.present? && (timestamp + SINGLE_THREAD_DEBOUNCE_TIME) > Time.current
end
end
def perform
find_storages do |storage|
result = service_for(storage).call(storage)
result.match(
on_success: ->(_) { storage.mark_as_healthy },
on_failure: ->(errors) { storage.mark_as_unhealthy(reason: errors.to_s) }
)
end
end
private
def find_storages(&)
::Storages::Storage
.automatic_management_enabled
.includes(:oauth_client)
.find_each(&)
end
def service_for(storage)
return NextcloudGroupFolderPropertiesSyncService if storage.provider_type_nextcloud?
return OneDriveManagedFolderSyncService if storage.provider_type_one_drive?
raise 'Unknown Storage'
end
end
end
@@ -1,66 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages
module ManageNextcloudIntegrationJobMixin
using Peripherals::ServiceResultRefinements
def perform
OpenProject::Mutex.with_advisory_lock(
::Storages::NextcloudStorage,
'sync_all_group_folders',
timeout_seconds: 0,
transaction: false
) do
::Storages::Storage.automatic_management_enabled.includes(:oauth_client).find_each do |storage|
result = service_for(storage).call(storage)
result.match(
on_success: ->(_) do
storage.mark_as_healthy
end,
on_failure: ->(errors) do
storage.mark_as_unhealthy(reason: errors.to_s)
end
)
end
true
end
end
private
def service_for(storage)
return NextcloudGroupFolderPropertiesSyncService if storage.provider_type_nextcloud?
return OneDriveManagedFolderSyncService if storage.provider_type_one_drive?
raise 'Unknown Storage'
end
end
end
@@ -28,8 +28,8 @@
class RemoveRenamedCronjobs < ActiveRecord::Migration[7.0]
def up
Delayed::Job.where("handler LIKE '%job_class: CleanupUncontaineredFileLinksJob%'").delete_all
Delayed::Job.where("handler LIKE '%job_class: ManageNextcloudIntegrationJob%'").delete_all
execute("DELETE FROM delayed_jobs WHERE handler LIKE '%job_class: CleanupUncontaineredFileLinksJob%'")
execute("DELETE FROM delayed_jobs WHERE handler LIKE '%job_class: ManageNextcloudIntegrationJob%'")
end
def down; end
@@ -46,11 +46,11 @@ module OpenProject::Storages
# please see comments inside ActsAsOpEngine class
include OpenProject::Plugins::ActsAsOpEngine
initializer 'openproject_storages.feature_decisions' do
initializer "openproject_storages.feature_decisions" do
OpenProject::FeatureDecisions.add :storage_file_picking_select_all
end
initializer 'openproject_storages.event_subscriptions' do
initializer "openproject_storages.event_subscriptions" do
Rails.application.config.after_initialize do
[
OpenProject::Events::MEMBER_CREATED,
@@ -62,29 +62,29 @@ module OpenProject::Storages
OpenProject::Events::PROJECT_UNARCHIVED
].each do |event|
OpenProject::Notifications.subscribe(event) do |_payload|
::Storages::ManageNextcloudIntegrationEventsJob.debounce
::Storages::ManageNextcloudIntegrationJob.debounce
end
end
OpenProject::Notifications.subscribe(
OpenProject::Events::OAUTH_CLIENT_TOKEN_CREATED
) do |payload|
if payload[:integration_type] == 'Storages::Storage'
::Storages::ManageNextcloudIntegrationEventsJob.debounce
if payload[:integration_type] == "Storages::Storage"
::Storages::ManageNextcloudIntegrationJob.debounce
end
end
OpenProject::Notifications.subscribe(
OpenProject::Events::ROLE_UPDATED
) do |payload|
if payload[:permissions_diff]&.intersect?(OpenProject::Storages::Engine.permissions)
::Storages::ManageNextcloudIntegrationEventsJob.debounce
::Storages::ManageNextcloudIntegrationJob.debounce
end
end
OpenProject::Notifications.subscribe(
OpenProject::Events::ROLE_DESTROYED
) do |payload|
if payload[:permissions]&.intersect?(OpenProject::Storages::Engine.permissions)
::Storages::ManageNextcloudIntegrationEventsJob.debounce
::Storages::ManageNextcloudIntegrationJob.debounce
end
end
@@ -95,8 +95,8 @@ module OpenProject::Storages
].each do |event|
OpenProject::Notifications.subscribe(event) do |payload|
if payload[:project_folder_mode] == :automatic
::Storages::ManageNextcloudIntegrationEventsJob.debounce
::Storages::ManageNextcloudIntegrationCronJob.ensure_scheduled!
::Storages::ManageNextcloudIntegrationJob.debounce
::Storages::ManageNextcloudIntegrationJob.disable_cron_job_if_needed
end
end
end
@@ -106,8 +106,8 @@ module OpenProject::Storages
# For documentation see the definition of register in "ActsAsOpEngine"
# This corresponds to the openproject-storage.gemspec
# Pass a block to the plugin (for defining permissions, menu items and the like)
register 'openproject-storages',
author_url: 'https://www.openproject.org',
register "openproject-storages",
author_url: "https://www.openproject.org",
bundled: true,
settings: {} do
# Defines permission constraints used in the module (controller, etc.)
@@ -142,14 +142,14 @@ module OpenProject::Storages
# condition ("if:"), caption and icon.
menu :admin_menu,
:storages_admin_settings,
{ controller: '/storages/admin/storages', action: :index },
{ controller: "/storages/admin/storages", action: :index },
if: Proc.new { User.current.admin? },
caption: :project_module_storages,
icon: 'hosting'
icon: "hosting"
menu :project_menu,
:settings_project_storages,
{ controller: '/storages/admin/project_storages', action: 'index' },
{ controller: "/storages/admin/project_storages", action: "index" },
caption: :project_module_storages,
parent: :settings
@@ -273,21 +273,28 @@ module OpenProject::Storages
end
# Add api endpoints specific to this module
add_api_endpoint 'API::V3::Root' do
add_api_endpoint "API::V3::Root" do
mount ::API::V3::Storages::StoragesAPI
mount ::API::V3::ProjectStorages::ProjectStoragesAPI
mount ::API::V3::FileLinks::FileLinksAPI
end
add_api_endpoint 'API::V3::WorkPackages::WorkPackagesAPI', :id do
add_api_endpoint "API::V3::WorkPackages::WorkPackagesAPI", :id do
mount ::API::V3::FileLinks::WorkPackagesFileLinksAPI
end
add_cron_jobs do
[
Storages::CleanupUncontaineredFileLinksJob,
Storages::ManageNextcloudIntegrationCronJob
]
{
'Storages::CleanupUncontaineredFileLinksJob': {
cron: "06 22 * * *",
class: ::Storages::CleanupUncontaineredFileLinksJob.name
},
'Storages::ManageNextcloudIntegrationJob': {
cron: "1 * * * *",
class: ::Storages::ManageNextcloudIntegrationJob.name
}
}
end
end
end
@@ -32,39 +32,37 @@ require 'spec_helper'
require_module_spec_helper
RSpec.describe Storages::CleanupUncontaineredFileLinksJob, type: :job do
it 'has a schedule set' do
expect(described_class.cron_expression).to eq('06 22 * * *')
end
describe '#perform' do
it 'removes uncontainered file_links which are old enough' do
grace_period = 10
allow(OpenProject::Configuration)
.to receive(:attachments_grace_period)
.and_return(grace_period)
it 'removes uncontainered file_links which are old enough' do
grace_period = 10
allow(OpenProject::Configuration)
.to receive(:attachments_grace_period)
.and_return(grace_period)
expect(Storages::FileLink.count).to eq(0)
expect(Storages::FileLink.count).to eq(0)
uncontainered_old = create(:file_link,
container_id: nil,
container_type: nil,
created_at: Time.current - grace_period.minutes - 1.second)
uncontainered_young = create(:file_link,
uncontainered_old = create(:file_link,
container_id: nil,
container_type: nil)
containered_old = create(:file_link,
container_id: 1,
created_at: Time.current - grace_period.minutes - 1.second)
containered_young = create(:file_link,
container_id: 1)
container_type: nil,
created_at: Time.current - grace_period.minutes - 1.second)
uncontainered_young = create(:file_link,
container_id: nil,
container_type: nil)
containered_old = create(:file_link,
container_id: 1,
created_at: Time.current - grace_period.minutes - 1.second)
containered_young = create(:file_link,
container_id: 1)
expect(Storages::FileLink.count).to eq(4)
expect(Storages::FileLink.count).to eq(4)
described_class.new.perform
described_class.new.perform
expect(Storages::FileLink.count).to eq(3)
file_link_ids = Storages::FileLink.pluck(:id).sort
expected_file_link_ids = [uncontainered_young.id, containered_old.id, containered_young.id].sort
expect(file_link_ids).to eq(expected_file_link_ids)
expect(file_link_ids).not_to include(uncontainered_old.id)
expect(Storages::FileLink.count).to eq(3)
file_link_ids = Storages::FileLink.pluck(:id).sort
expected_file_link_ids = [uncontainered_young.id, containered_old.id, containered_young.id].sort
expect(file_link_ids).to eq(expected_file_link_ids)
expect(file_link_ids).not_to include(uncontainered_old.id)
end
end
end
@@ -1,154 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require_module_spec_helper
RSpec.describe Storages::ManageNextcloudIntegrationCronJob, :webmock, type: :job do
it 'has a schedule set' do
expect(described_class.cron_expression).to eq('1 * * * *')
end
describe '.ensure_scheduled!' do
before { ActiveJob::Base.disable_test_adapter }
subject { described_class.ensure_scheduled! }
context 'when there is active nextcloud project storage' do
shared_let(:storage1) { create(:nextcloud_storage, :as_automatically_managed) }
shared_let(:project_storage) { create(:project_storage, :as_automatically_managed, storage: storage1) }
it 'schedules cron_job if not scheduled' do
expect(described_class.scheduled?).to be(false)
expect(described_class.delayed_job_query.count).to eq(0)
subject
expect(described_class.scheduled?).to be(true)
expect(described_class.delayed_job_query.count).to eq(1)
end
it 'does not schedules cron_job if already scheduled' do
described_class.ensure_scheduled!
expect(described_class.scheduled?).to be(true)
expect(described_class.delayed_job_query.count).to eq(1)
subject
expect(described_class.scheduled?).to be(true)
expect(described_class.delayed_job_query.count).to eq(1)
end
end
context 'when there is no active nextcloud project storage' do
it 'does nothing but removes cron_job' do
described_class.set(cron: described_class.cron_expression).perform_later
expect(described_class.scheduled?).to be(true)
expect(described_class.delayed_job_query.count).to eq(1)
subject
expect(described_class.scheduled?).to be(false)
expect(described_class.delayed_job_query.count).to eq(0)
end
end
end
describe '.perform' do
subject { described_class.new.perform }
context 'when lock is free' do
it 'responds with true' do
expect(subject).to be(true)
end
it 'calls GroupFolderPropertiesSyncService for each automatically managed storage' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
storage2 = create(:nextcloud_storage, :as_not_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.success)
expect(subject).to be(true)
expect(Storages::NextcloudGroupFolderPropertiesSyncService).to have_received(:call).with(storage1).once
expect(Storages::NextcloudGroupFolderPropertiesSyncService).not_to have_received(:call).with(storage2)
end
it 'marks storage as healthy if sync was successful' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.success)
Timecop.freeze('2023-03-14T15:17:00Z') do
expect do
subject
storage1.reload
end.to(
change(storage1, :health_changed_at).to(Time.now.utc)
.and(change(storage1, :health_status).from('pending').to('healthy'))
)
end
end
it 'marks storage as unhealthy if sync was unsuccessful' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.failure(errors: Storages::StorageError.new(code: :not_found)))
Timecop.freeze('2023-03-14T15:17:00Z') do
expect do
subject
storage1.reload
end.to(
change(storage1, :health_changed_at).to(Time.now.utc)
.and(change(storage1, :health_status).from('pending').to('unhealthy'))
.and(change(storage1, :health_reason).from(nil).to('not_found'))
)
end
end
end
context 'when lock is unfree' do
it 'responds with false' do
allow(ApplicationRecord).to receive(:with_advisory_lock).and_return(false)
expect(subject).to be(false)
expect(ApplicationRecord).to have_received(:with_advisory_lock).with(
'sync_all_group_folders',
timeout_seconds: 0,
transaction: false
).once
end
end
end
end
@@ -1,88 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require_module_spec_helper
RSpec.describe Storages::ManageNextcloudIntegrationEventsJob, type: :job do
describe '.priority' do
it 'has a maximum priority' do
expect(described_class.priority).to eq(7)
end
end
describe '.debounce' do
context 'when has been debounced by other thread' do
before do
Rails.cache.write(described_class::KEY, Time.current)
end
it 'does nothing' do
expect { described_class.debounce }.not_to change(enqueued_jobs, :count)
end
end
context 'when has not been debounced by other thread' do
it 'schedules a job' do
expect { described_class.debounce }.to change(enqueued_jobs, :count).from(0).to(1)
end
it 'hits cache once when called 1000 times in a short period of time' do
allow(Rails.cache).to receive(:fetch).and_call_original
expect do
1000.times { described_class.debounce }
end.to change(enqueued_jobs, :count).from(0).to(1)
expect(Rails.cache).to have_received(:fetch).once
end
end
end
describe '#perform' do
it 'responds with true when parent perform responds with true' do
allow(OpenProject::Mutex).to receive(:with_advisory_lock).and_return(true)
allow(described_class).to receive(:debounce)
expect(described_class.new.perform).to be(true)
expect(described_class).not_to have_received(:debounce)
end
it 'debounces itself when parent perform responds with false' do
allow(OpenProject::Mutex).to receive(:with_advisory_lock).and_return(false)
allow(described_class).to receive(:debounce)
expect(described_class.new.perform).to be(false)
expect(described_class).to have_received(:debounce).once
end
end
end
@@ -0,0 +1,159 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require_module_spec_helper
RSpec.describe Storages::ManageNextcloudIntegrationJob, :webmock, type: :job do
describe '.debounce' do
context 'when has been debounced by other thread' do
before { ActiveJob::Base.disable_test_adapter }
it 'does not change the number of enqueued jobs' do
expect(GoodJob::Job.count).to eq(0)
expect(described_class.perform_later.successfully_enqueued?).to be(true)
expect(described_class.perform_later).to be(false)
expect(GoodJob::Job.count).to eq(1)
expect { described_class.debounce }.not_to change(GoodJob::Job, :count)
end
end
context 'when has not been debounced by other thread' do
it 'schedules a job' do
expect { described_class.debounce }.to change(enqueued_jobs, :count).from(0).to(1)
end
it 'tries to schedule once when called 1000 times in a short period of time' do
expect_any_instance_of(ActiveJob::ConfiguredJob)
.to receive(:perform_later).once.and_call_original
expect do
1000.times { described_class.debounce }
end.to change(enqueued_jobs, :count).from(0).to(1)
end
end
end
describe '.disable_cron_job_if_needed' do
before { ActiveJob::Base.disable_test_adapter }
subject { described_class.disable_cron_job_if_needed }
context 'when there is an active nextcloud project storage' do
shared_let(:storage1) { create(:nextcloud_storage, :as_automatically_managed) }
shared_let(:project_storage) { create(:project_storage, :as_automatically_managed, storage: storage1) }
it 'enables the cron_job if was disabled before' do
GoodJob::Setting.cron_key_disable(described_class::CRON_JOB_KEY)
good_job_setting = GoodJob::Setting.first
expect(good_job_setting.key).to eq("cron_keys_disabled")
expect(good_job_setting.value).to eq(["Storages::ManageNextcloudIntegrationJob"])
expect { subject }.not_to change(GoodJob::Setting, :count).from(1)
good_job_setting.reload
expect(good_job_setting.key).to eq("cron_keys_disabled")
expect(good_job_setting.value).to eq([])
end
it 'does nothing if the cron_job is not disabled' do
expect(GoodJob::Setting.cron_key_enabled?(described_class::CRON_JOB_KEY)).to be(true)
expect { subject }.not_to change(GoodJob::Setting, :count).from(0)
expect(GoodJob::Setting.cron_key_enabled?(described_class::CRON_JOB_KEY)).to be(true)
end
end
context 'when there is no active nextcloud project storage' do
it 'disables the cron job' do
expect { subject }.to change(GoodJob::Setting, :count).from(0).to(1)
good_job_setting = GoodJob::Setting.first
expect(good_job_setting.key).to eq("cron_keys_disabled")
expect(good_job_setting.value).to eq(["Storages::ManageNextcloudIntegrationJob"])
end
end
end
describe '.perform' do
subject { described_class.new.perform }
it 'calls NextcloudGroupFolderPropertiesSyncService for each automatically managed storage' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
storage2 = create(:nextcloud_storage, :as_not_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.success)
subject
expect(Storages::NextcloudGroupFolderPropertiesSyncService).to have_received(:call).with(storage1).once
expect(Storages::NextcloudGroupFolderPropertiesSyncService).not_to have_received(:call).with(storage2)
end
it 'marks storage as healthy if sync was successful' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.success)
Timecop.freeze('2023-03-14T15:17:00Z') do
expect do
subject
storage1.reload
end.to(
change(storage1, :health_changed_at).to(Time.now.utc)
.and(change(storage1, :health_status).from('pending').to('healthy'))
)
end
end
it 'marks storage as unhealthy if sync was unsuccessful' do
storage1 = create(:nextcloud_storage, :as_automatically_managed)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call).with(storage1).and_return(ServiceResult.failure(errors: Storages::StorageError.new(code: :not_found)))
Timecop.freeze('2023-03-14T15:17:00Z') do
expect do
subject
storage1.reload
end.to(
change(storage1, :health_changed_at).to(Time.now.utc)
.and(change(storage1, :health_status).from('pending').to('unhealthy'))
.and(change(storage1, :health_reason).from(nil).to('not_found'))
)
end
end
end
end
@@ -26,10 +26,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class CleanupWebhookLogsJob < Cron::CronJob
# runs at 5:28 on Sunday
self.cron_expression = '28 5 * * 7'
class CleanupWebhookLogsJob < ApplicationJob
# Clean any logs older than 7 days
def perform
::Webhooks::Log
@@ -51,6 +51,13 @@ module OpenProject::Webhooks
end
end
add_cron_jobs { CleanupWebhookLogsJob }
add_cron_jobs do
{
CleanupWebhookLogsJob: {
cron: '28 5 * * 7', # runs at 5:28 on Sunday
class: ::CleanupWebhookLogsJob.name
}
}
end
end
end
+1 -1
View File
@@ -29,7 +29,7 @@ else
log_ko "openproject server is NOT running"
fi
if ps -u "$APP_USER" -f | grep -q "rake jobs:work" ; then
if ps -u "$APP_USER" -f | grep -q "good_job start" ; then
log_ok "openproject background job worker is running"
else
log_ko "openproject background job worker is NOT running"
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash -e
QUIET=true bundle exec rake jobs:work
QUIET=true bundle exec good_job start
@@ -37,13 +37,6 @@ RSpec.describe Settings::WorkingDaysParamsContract do
let(:contract) do
described_class.new(setting, current_user, params:)
end
let(:apply_job_scheduled) { false }
before do
allow(WorkPackages::ApplyWorkingDaysChangeJob)
.to receive(:scheduled?)
.and_return(apply_job_scheduled)
end
it_behaves_like 'contract is valid for active admins and invalid for regular users'
@@ -55,7 +48,10 @@ RSpec.describe Settings::WorkingDaysParamsContract do
context 'with an ApplyWorkingDaysChangeJob already existing' do
let(:params) { { working_days: [1, 2, 3] } }
let(:apply_job_scheduled) { true }
before do
ActiveJob::Base.disable_test_adapter
WorkPackages::ApplyWorkingDaysChangeJob.perform_later
end
include_examples 'contract is invalid', base: :previous_working_day_changes_unprocessed
end
+1 -1
View File
@@ -387,7 +387,7 @@ RSpec.describe RolesController do
subject
expect(enqueued_jobs.count).to eq(1)
expect(enqueued_jobs[0][:job]).to eq(Storages::ManageNextcloudIntegrationEventsJob)
expect(enqueued_jobs[0][:job]).to eq(Storages::ManageNextcloudIntegrationJob)
expect(response).to redirect_to roles_path
expect(Role.count).to eq(0)
end
+2 -5
View File
@@ -163,11 +163,8 @@ RSpec.describe 'Working Days', :js, :with_cuprite do
it 'shows an error when a previous change to the working days configuration isn\'t processed yet' do
# Have a job already scheduled
# Attempting to set the job via simply using the UI would require to change the test setup of how
# delayed jobs are handled.
ActiveJob::QueueAdapters::DelayedJobAdapter
.new
.enqueue(WorkPackages::ApplyWorkingDaysChangeJob.new(user_id: 5))
ActiveJob::Base.disable_test_adapter
WorkPackages::ApplyWorkingDaysChangeJob.perform_later(user_id: 5)
uncheck 'Tuesday'
click_on 'Apply changes'
@@ -9,10 +9,10 @@ RSpec.describe "Reminder email sending", :js, :with_cuprite do
let(:work_package) { create(:work_package, project:) }
let(:watched_work_package) { create(:work_package, project:, watcher_users: [current_user]) }
let(:involved_work_package) { create(:work_package, project:, assigned_to: current_user) }
# The run_at time of the delayed job used for scheduling the reminder mails
# ApplicationJob#job_scheduled_at is used for scheduling the reminder mails
# needs to be within a time frame eligible for sending out mails for the chose
# time zone. For the time zone Hawaii (UTC-10) this means between 8:00:00 and 8:14:59 UTC.
# The job is scheduled to run every 15 min so the run_at will in production always move between the quarters of an hour.
# The job is scheduled to run every 15 min so the scheduled_at will in production always move between the quarters of an hour.
# The current time can be way behind that.
let(:current_utc_time) { ActiveSupport::TimeZone['Pacific/Honolulu'].parse("2021-09-30T08:34:10").utc }
let(:job_run_at) { ActiveSupport::TimeZone['Pacific/Honolulu'].parse("2021-09-30T08:00:00").utc }
@@ -57,13 +57,10 @@ RSpec.describe "Reminder email sending", :js, :with_cuprite do
work_package
involved_work_package
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
ActiveJob::Base.disable_test_adapter
# There is no delayed_job associated when using the testing backend of ActiveJob
# so we have to mock it.
allow(Notifications::ScheduleReminderMailsJob)
.to receive(:delayed_job)
.and_return(instance_double(Delayed::Backend::ActiveRecord::Job, run_at: job_run_at))
scheduled_job = Notifications::ScheduleReminderMailsJob.perform_later
GoodJob::Job.where(id: scheduled_job.job_id).update_all(scheduled_at: job_run_at)
end
it 'sends a digest mail based on the configuration', with_settings: { journal_aggregation_time_minutes: 0 } do
@@ -88,13 +85,9 @@ RSpec.describe "Reminder email sending", :js, :with_cuprite do
involved_work_package.save!
end
# The Job is triggered by time so we mock it and the jobs started by it being triggered
Notifications::ScheduleReminderMailsJob.perform_later
2.times { perform_enqueued_jobs }
expect(ActionMailer::Base.deliveries.length)
.to be 1
2.times { GoodJob.perform_inline }
expect(ActionMailer::Base.deliveries.length).to be 1
expect(ActionMailer::Base.deliveries.first.subject)
.to eql "OpenProject - 1 unread notification including a mention"
end
+4 -12
View File
@@ -35,26 +35,17 @@ RSpec.describe 'Projects#destroy', :js, :with_cuprite do
current_user { create(:admin) }
before do
# Disable background worker
allow(Delayed::Worker)
.to receive(:delay_jobs)
.and_return(false)
project_page.visit!
end
before { project_page.visit! }
it 'destroys the project' do
# Confirm the deletion
# Without confirmation, the button is disabled
expect(danger_zone)
.to be_disabled
expect(danger_zone).to be_disabled
# With wrong confirmation, the button is disabled
danger_zone.confirm_with("#{project.identifier}_wrong")
expect(danger_zone)
.to be_disabled
expect(danger_zone).to be_disabled
# With correct confirmation, the button is enabled
# and the project can be deleted
@@ -63,6 +54,7 @@ RSpec.describe 'Projects#destroy', :js, :with_cuprite do
danger_zone.danger_button.click
expect(page).to have_css '.op-toast.-success', text: I18n.t('projects.delete.scheduled')
expect(project.reload).to eq(project)
perform_enqueued_jobs
+12 -15
View File
@@ -37,7 +37,7 @@ RSpec.describe OpenProject::Events do
end
before do
allow(Storages::ManageNextcloudIntegrationEventsJob).to receive(:debounce)
allow(Storages::ManageNextcloudIntegrationJob).to receive(:debounce)
end
%w[
@@ -53,7 +53,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).not_to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).not_to have_received(:debounce)
end
end
@@ -62,16 +62,13 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:debounce)
end
it do
# Check for message being called instead of checking the job arrival to the queue
# because it is not simple adding to the queue in ::Storages::ManageNextcloudIntegrationCronJob.ensure_scheduled!
# With this method cron_job can be actually removed if not needed.
allow(Storages::ManageNextcloudIntegrationCronJob).to receive(:ensure_scheduled!)
allow(Storages::ManageNextcloudIntegrationJob).to receive(:disable_cron_job_if_needed)
subject
expect(Storages::ManageNextcloudIntegrationCronJob).to have_received(:ensure_scheduled!)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:disable_cron_job_if_needed)
end
end
end
@@ -93,7 +90,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:debounce)
end
end
end
@@ -106,7 +103,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).not_to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).not_to have_received(:debounce)
end
end
@@ -115,7 +112,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:debounce)
end
end
end
@@ -128,7 +125,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).not_to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).not_to have_received(:debounce)
end
end
@@ -137,7 +134,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:debounce)
end
end
end
@@ -150,7 +147,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).not_to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).not_to have_received(:debounce)
end
end
@@ -159,7 +156,7 @@ RSpec.describe OpenProject::Events do
it do
subject
expect(Storages::ManageNextcloudIntegrationEventsJob).to have_received(:debounce)
expect(Storages::ManageNextcloudIntegrationJob).to have_received(:debounce)
end
end
end
@@ -27,9 +27,11 @@
#++
require 'spec_helper'
require Rails.root.join("db/migrate/20220202140507_reorder_project_children.rb")
RSpec.describe Projects::ReorderHierarchyJob, type: :model do
subject(:job) { described_class.perform_now }
RSpec.describe ReorderProjectChildren, type: :model do
# Silencing migration logs, since we are not interested in that during testing
subject(:run_migration) { ActiveRecord::Migration.suppress_messages { described_class.new.up } }
shared_let(:parent_project_a) { create(:project, name: 'ParentA') }
shared_let(:parent_project_b) { create(:project, name: 'ParentB') }
-5
View File
@@ -36,11 +36,6 @@ RSpec.describe BasicData::SettingSeeder do
let(:new_project_role) { basic_seed_data.find_reference(:default_role_project_admin) }
let(:closed_status) { basic_seed_data.find_reference(:default_status_closed) }
before do
allow(ActionMailer::Base).to receive(:perform_deliveries).and_return(false)
allow(Delayed::Worker).to receive(:delay_jobs).and_return(false)
end
it 'applies initial settings' do
expect(setting_seeder).to be_applicable
@@ -1,65 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
RSpec.describe "NonExistingJobClass" do
let!(:job_with_non_existing_class) do
handler = <<~JOB.strip
--- !ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper
job_data:
job_class: WhichShallNotBeNamedJob
job_id: 8f72c3c9-a1e0-4e46-b0f2-b517288bb76c
provider_job_id:
queue_name: default
priority: 5
arguments:
- 42
executions: 0
exception_executions: {}
locale: en
timezone: UTC
enqueued_at: '2022-12-05T09:41:39Z'
JOB
Delayed::Job.create(handler:)
end
before do
# allow to inspect the job is marked as failed after failure in the test
allow(Delayed::Worker).to receive(:destroy_failed_jobs).and_return(false)
end
it 'does not crash the worker when processed' do
expect { Delayed::Worker.new(exit_on_complete: true).start }
.not_to raise_error
job_with_non_existing_class.reload
expect(job_with_non_existing_class.last_error).to include("uninitialized constant WhichShallNotBeNamedJob")
expect(job_with_non_existing_class.failed_at).to be_within(1).of(Time.current)
end
end
@@ -32,42 +32,30 @@ RSpec.describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, wi
include ActiveSupport::Testing::TimeHelpers
shared_let(:project) { create(:project, name: 'main') }
# Paris and Berlin are both UTC+01:00 (CET) or UTC+02:00 (CEST)
shared_let(:timezone_paris) { ActiveSupport::TimeZone['Europe/Paris'] }
# Kathmandu is UTC+05:45 (no DST)
shared_let(:timezone_kathmandu) { ActiveSupport::TimeZone['Asia/Kathmandu'] }
shared_let(:user_paris) do
create(
:user,
firstname: 'Paris',
preferences: { time_zone: timezone_paris.name }
)
create(:user,
firstname: 'Paris',
preferences: { time_zone: timezone_paris.name })
end
shared_let(:user_kathmandu) do
create(
:user,
firstname: 'Kathmandu',
preferences: { time_zone: timezone_kathmandu.name }
)
create(:user,
firstname: 'Kathmandu',
preferences: { time_zone: timezone_kathmandu.name })
end
let(:schedule_job) do
described_class.ensure_scheduled!
described_class.delayed_job
end
let(:scheduled_job) { described_class.perform_later }
before do
# We need to access the job as stored in the database to get at the run_at time persisted there
allow(ActiveJob::Base)
.to receive(:queue_adapter)
.and_return(ActiveJob::QueueAdapters::DelayedJobAdapter.new)
schedule_job
ActiveJob::Base.disable_test_adapter
scheduled_job
end
def set_scheduled_time(run_at)
schedule_job.update_column(:run_at, run_at)
def set_scheduled_time(scheduled_at)
GoodJob::Job.where(id: scheduled_job.job_id).update_all(scheduled_at:)
end
# Converts "hh:mm" into { hour: h, min: m }
@@ -82,28 +70,25 @@ RSpec.describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, wi
def run_job(scheduled_at: '1:00', local_time: '1:04', timezone: timezone_paris)
set_scheduled_time(timezone_time(scheduled_at, timezone))
travel_to(timezone_time(local_time, timezone)) do
schedule_job.reload.invoke_job
GoodJob.perform_inline
# scheduled_job.reload.invoke_job
yield if block_given?
end
end
def deserialized_of_job(job)
deserializer_class = Class.new do
include(ActiveJob::Arguments)
end
deserializer = deserializer_class.new
deserializer.deserialize(job.payload_object.job_data).to_h
def deserialize_job(job)
deserializer_class = Class.new { include(ActiveJob::Arguments) }
deserializer_class.new
.deserialize(job.serialized_params)
.to_h
end
def expect_job(job, klass, *arguments)
job_data = deserialized_of_job(job)
expect(job_data['job_class'])
.to eql klass
expect(job_data['arguments'])
.to match_array arguments
def expect_job(job, *arguments)
job_data = deserialize_job(job)
expect(job_data['job_class']).to eql(job.job_class)
expect(job_data['arguments']).to match_array arguments
expect(job_data['executions']).to eq 0
end
shared_examples_for 'job execution creates date alerts creation job' do
@@ -115,9 +100,12 @@ RSpec.describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, wi
it 'creates the job for the user' do
expect do
run_job(timezone:, scheduled_at:, local_time:) do
expect_job(Delayed::Job.last, "Notifications::CreateDateAlertsNotificationsJob", user)
j = GoodJob::Job.where(job_class: "Notifications::CreateDateAlertsNotificationsJob")
.order(created_at: :desc)
.last
expect_job(j, user)
end
end.to change(Delayed::Job, :count).by 1
end.to change(GoodJob::Job, :count).by 1
end
end
@@ -129,7 +117,7 @@ RSpec.describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, wi
it 'creates no job' do
expect do
run_job(timezone:, scheduled_at:, local_time:)
end.not_to change(Delayed::Job, :count)
end.not_to change(GoodJob::Job, :count)
end
end
@@ -29,69 +29,46 @@
require 'spec_helper'
RSpec.describe Notifications::ScheduleReminderMailsJob, type: :job do
subject(:job) { scheduled_job.invoke_job }
let(:scheduled_job) do
described_class.ensure_scheduled!
Delayed::Job.first
end
let(:scheduled_job) { described_class.perform_later }
let(:ids) { [23, 42] }
let(:run_at) { scheduled_job.run_at }
before do
# We need to access the job as stored in the database to get at the run_at time persisted there
allow(ActiveJob::Base)
.to receive(:queue_adapter)
.and_return(ActiveJob::QueueAdapters::DelayedJobAdapter.new)
scheduled_job.update_column(:run_at, run_at)
# We need to access the job as stored in the database to get at the scheduled_at time persisted there
ActiveJob::Base.disable_test_adapter
scheduled_job
scope = instance_double(ActiveRecord::Relation)
allow(User)
.to receive(:having_reminder_mail_to_send)
.and_return(scope)
allow(scope)
.to receive(:pluck)
.with(:id)
.and_return(ids)
allow(User).to receive(:having_reminder_mail_to_send).and_return(scope)
allow(scope).to receive(:pluck).with(:id).and_return(ids)
end
describe '#perform' do
shared_examples_for 'schedules reminder mails' do
it 'schedules reminder jobs for every user with a reminder mails to be sent' do
expect { subject }
.to change(Delayed::Job, :count)
.by(2)
expect { GoodJob.perform_inline }.to change(GoodJob::Job, :count).by(2)
jobs = Delayed::Job.all.map do |job|
YAML.safe_load(job.handler, permitted_classes: [ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper])
end
reminder_jobs = jobs.select { |job| job.job_data['job_class'] == "Mails::ReminderJob" }
expect(reminder_jobs[0].job_data['arguments'])
.to contain_exactly(23)
expect(reminder_jobs[1].job_data['arguments'])
.to contain_exactly(42)
arguments_from_both_jobs =
GoodJob::Job.where(job_class: "Mails::ReminderJob")
.flat_map {|i| i.serialized_params["arguments"]}
.sort
expect(arguments_from_both_jobs).to eq(ids)
end
it 'queries with the intended job execution time (which might have been missed due to high load)' do
subject
GoodJob.perform_inline
expect(User)
.to have_received(:having_reminder_mail_to_send)
.with(run_at)
expect(User).to have_received(:having_reminder_mail_to_send).with(scheduled_job.job_scheduled_at)
end
end
it_behaves_like 'schedules reminder mails'
context 'with a job that missed some runs' do
let(:run_at) { scheduled_job.run_at - 3.hours }
before do
GoodJob::Job
.where(id: scheduled_job.job_id)
.update_all(scheduled_at: scheduled_job.job_scheduled_at - 3.hours)
end
it_behaves_like 'schedules reminder mails'
end
@@ -1811,26 +1811,4 @@ RSpec.describe WorkPackages::ApplyWorkingDaysChangeJob do
end
end
end
describe '.scheduled?' do
context 'with a job already scheduled in the DB' do
before do
ActiveJob::QueueAdapters::DelayedJobAdapter
.new
.enqueue(described_class.new(user_id: 5, previous_non_working_days: [1, 2]))
end
it 'is true' do
expect(described_class)
.to be_scheduled
end
end
context 'without a job already scheduled in the DB' do
it 'is false' do
expect(described_class)
.not_to be_scheduled
end
end
end
end