From dd3803d812f18c3c435bbfa388a8880902dc90a2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:15:08 +0300 Subject: [PATCH 01/13] Record whether a label was created inline Admin-curated labels and labels created from the work package form need telling apart, since only the latter are removed again when unused. See COMMS-1050. --- app/models/label.rb | 7 ++++ ...1021100000_add_created_inline_to_labels.rb | 34 +++++++++++++++++++ spec/factories/label_factory.rb | 4 +++ spec/models/label_spec.rb | 30 ++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 db/migrate/20261021100000_add_created_inline_to_labels.rb diff --git a/app/models/label.rb b/app/models/label.rb index 6ad8f287f83a..ddeb620a84ca 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -58,6 +58,13 @@ class Label < ApplicationRecord where("LOWER(labels.name) = LOWER(?)", normalize_value_for(:name, name)) } + scope :created_inline, -> { where(created_inline: true) } + + scope :never_used, -> { + where.not(id: Labeling.select(:label_id)) + .where.not(id: Journal::LabelJournal.select(:label_id)) + } + normalizes :name, with: -> { it.squish } validates :name, diff --git a/db/migrate/20261021100000_add_created_inline_to_labels.rb b/db/migrate/20261021100000_add_created_inline_to_labels.rb new file mode 100644 index 000000000000..e0b27f9da419 --- /dev/null +++ b/db/migrate/20261021100000_add_created_inline_to_labels.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 AddCreatedInlineToLabels < ActiveRecord::Migration[8.1] + def change + add_column :labels, :created_inline, :boolean, null: false, default: false + end +end diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb index dc8e7eeeda05..e1745df0a164 100644 --- a/spec/factories/label_factory.rb +++ b/spec/factories/label_factory.rb @@ -31,5 +31,9 @@ factory :label do sequence(:name) { "Label #{it}" } author factory: :user + + trait :created_inline do + created_inline { true } + end end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 0a93f90002f7..d11d0089befd 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -104,6 +104,36 @@ def label_work_packages(label, project, count:) end end + describe ".created_inline" do + it "returns only labels created inline" do + inline = create(:label, :created_inline) + create(:label) + + expect(described_class.created_inline).to contain_exactly(inline) + end + end + + describe ".never_used" do + let!(:unused) { create(:label) } + + it "includes labels without labelings or journal snapshots" do + expect(described_class.never_used).to contain_exactly(unused) + end + + it "excludes labels with a labeling" do + create(:labeling, label: create(:label)) + + expect(described_class.never_used).to contain_exactly(unused) + end + + it "excludes labels only referenced by a journal snapshot" do + journaled = create(:label) + Journal::LabelJournal.create!(journal: create(:work_package).journals.last, label: journaled) + + expect(described_class.never_used).to contain_exactly(unused) + end + end + describe ".page_of" do let!(:alpha) { create(:label, name: "Alpha") } let!(:bravo) { create(:label, name: "bravo") } From 425d4579fc599f587d712cfbe9a86a849b959e0f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:16:44 +0300 Subject: [PATCH 02/13] Flag labels created from the work package form as inline Only labels created through the API's find-or-create path are marked. Editing a label in the administration turns it into a curated one. See COMMS-1050. --- app/contracts/labels/create_contract.rb | 2 ++ app/services/labels/find_or_create_service.rb | 2 +- app/services/labels/set_attributes_service.rb | 13 +++++++++++++ spec/services/labels/create_service_spec.rb | 4 ++++ spec/services/labels/find_or_create_service_spec.rb | 2 ++ spec/services/labels/update_service_spec.rb | 11 +++++++++++ 6 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/contracts/labels/create_contract.rb b/app/contracts/labels/create_contract.rb index aa865cd7f669..7e2b290e70ad 100644 --- a/app/contracts/labels/create_contract.rb +++ b/app/contracts/labels/create_contract.rb @@ -29,6 +29,8 @@ module Labels class CreateContract < BaseContract + attribute :created_inline + validate :user_allowed_to_create private diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index e4bc5c558dcf..80b825316930 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -46,7 +46,7 @@ def call(name:) private def create(name) - call = CreateService.new(user:).call(name:) + call = CreateService.new(user:).call(name:, created_inline: true) return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken) existing(name) diff --git a/app/services/labels/set_attributes_service.rb b/app/services/labels/set_attributes_service.rb index f6aec6c7dfe3..87c8f7728fe4 100644 --- a/app/services/labels/set_attributes_service.rb +++ b/app/services/labels/set_attributes_service.rb @@ -31,6 +31,19 @@ module Labels class SetAttributesService < ::BaseServices::SetAttributes private + def set_attributes(params) + super + curate_on_edit + end + + def curate_on_edit + return unless model.persisted? + + model.change_by_system do + model.created_inline = false + end + end + def set_default_attributes(_params) model.change_by_system do model.author = user diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb index 7b94595d29d4..c6eb9a40af4c 100644 --- a/spec/services/labels/create_service_spec.rb +++ b/spec/services/labels/create_service_spec.rb @@ -48,6 +48,10 @@ expect(label.name).to eq("Bug") end + it "does not flag the label as created inline" do + expect(subject.result).not_to be_created_inline + end + it "fails when the name is already taken, case-insensitively" do create(:label, name: "Bug") diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb index 434c2b4e550b..acab296b6c20 100644 --- a/spec/services/labels/find_or_create_service_spec.rb +++ b/spec/services/labels/find_or_create_service_spec.rb @@ -43,6 +43,7 @@ expect(call.result).to be_persisted expect(call.result.name).to eq("Bug") expect(call.result).to be_previously_new_record + expect(call.result).to be_created_inline end end @@ -57,6 +58,7 @@ expect(call).to be_success expect(call.result).to eq(existing) expect(call.result).not_to be_previously_new_record + expect(call.result).not_to be_created_inline end context "with a name Ruby and Postgres lowercase differently" do diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb index 09d2ff911b32..66b28a0f4e6d 100644 --- a/spec/services/labels/update_service_spec.rb +++ b/spec/services/labels/update_service_spec.rb @@ -48,6 +48,17 @@ expect(label.reload.name).to eq("Defect") end + context "with a label created inline" do + let(:label) { create(:label, :created_inline, name: "Bug") } + + it "turns it into a curated label" do + result = instance.call(name: "Defect") + + expect(result).to be_success + expect(label.reload).not_to be_created_inline + end + end + context "when the new name is already taken in a different case" do before { create(:label, name: "defect") } From 5885f48239d4ad9b951918bca0b7431b12f03870 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:18:30 +0300 Subject: [PATCH 03/13] Build the label journal row through its factory in the label spec --- spec/models/label_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index d11d0089befd..c8d7df87d099 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -128,7 +128,7 @@ def label_work_packages(label, project, count:) it "excludes labels only referenced by a journal snapshot" do journaled = create(:label) - Journal::LabelJournal.create!(journal: create(:work_package).journals.last, label: journaled) + create(:journal_label_journal, journal: create(:work_package).journals.last, label: journaled) expect(described_class.never_used).to contain_exactly(unused) end From b33c8885cdc8c62d403da043e00be126919b154d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:19:06 +0300 Subject: [PATCH 04/13] Remove unused inline-created labels nightly Deletes labels created from the work package form that no work package carries and no journal ever snapshotted, so a label removed again inside the journal aggregation window vanishes while one that was part of a work package's history stays. The one day grace period covers the gap between creating the label through the API and saving the work package that uses it, which can be a form left open for a long time. See COMMS-1050. --- .../labels/cleanup_unused_inline_job.rb | 38 +++++++ config/initializers/cronjobs.rb | 4 + .../labels/cleanup_unused_inline_job_spec.rb | 106 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 app/workers/labels/cleanup_unused_inline_job.rb create mode 100644 spec/workers/labels/cleanup_unused_inline_job_spec.rb diff --git a/app/workers/labels/cleanup_unused_inline_job.rb b/app/workers/labels/cleanup_unused_inline_job.rb new file mode 100644 index 000000000000..e88645d8aa7b --- /dev/null +++ b/app/workers/labels/cleanup_unused_inline_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Labels::CleanupUnusedInlineJob < ApplicationJob + GRACE_PERIOD = 1.day + + queue_with_priority :low + + def perform + Label.created_inline.never_used.where(created_at: ..GRACE_PERIOD.ago).delete_all + end +end diff --git a/config/initializers/cronjobs.rb b/config/initializers/cronjobs.rb index aa20fa110f4b..55e5725374fc 100644 --- a/config/initializers/cronjobs.rb +++ b/config/initializers/cronjobs.rb @@ -78,6 +78,10 @@ "AI::TextTransformRuns::CleanupJob": { cron: "*/10 * * * *", class: AI::TextTransformRuns::CleanupJob.name + }, + "Labels::CleanupUnusedInlineJob": { + cron: "37 2 * * *", + class: Labels::CleanupUnusedInlineJob.name } } ) diff --git a/spec/workers/labels/cleanup_unused_inline_job_spec.rb b/spec/workers/labels/cleanup_unused_inline_job_spec.rb new file mode 100644 index 000000000000..ffad4fef9af7 --- /dev/null +++ b/spec/workers/labels/cleanup_unused_inline_job_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Labels::CleanupUnusedInlineJob, type: :job do + let(:label) { create(:label, :created_inline, created_at: 2.days.ago) } + + it "is scheduled nightly" do + expect(Rails.application.config.good_job.cron).to have_key(:"Labels::CleanupUnusedInlineJob") + end + + it "deletes an inline label older than the grace period that was never used" do + label + + expect { described_class.perform_now }.to change(Label, :count).by(-1) + expect(Label.exists?(label.id)).to be(false) + end + + it "keeps an inline label younger than the grace period" do + young = create(:label, :created_inline, created_at: 1.hour.ago) + + expect { described_class.perform_now }.not_to change(Label, :count) + expect(Label.exists?(young.id)).to be(true) + end + + it "keeps an admin-created label that was never used" do + curated = create(:label, created_at: 2.days.ago) + + expect { described_class.perform_now }.not_to change(Label, :count) + expect(Label.exists?(curated.id)).to be(true) + end + + it "keeps an inline label that currently labels a work package" do + create(:labeling, label:) + + expect { described_class.perform_now }.not_to change(Label, :count) + end + + it "keeps an inline label that only a journal snapshot references" do + create(:journal_label_journal, journal: create(:work_package).journals.last, label:) + + expect { described_class.perform_now }.not_to change(Label, :count) + end + + describe "labels added to and removed from a work package" do + shared_let(:user) { create(:admin) } + shared_let(:work_package) do + create(:work_package, journals: { 10.minutes.ago => { user: } }) + end + + current_user { user } + + def set_labels(label_ids) + WorkPackages::UpdateService.new(user:, model: work_package).call(label_ids:) + end + + context "when both changes happen within the journal aggregation time", + with_settings: { journal_aggregation_time_minutes: 5 } do + it "leaves no journal snapshot behind and the label is deleted" do + set_labels([label.id]) + set_labels([]) + + expect(Journal::LabelJournal.where(label:)).to be_empty + expect { described_class.perform_now }.to change(Label, :count).by(-1) + end + end + + context "when the removal happens after the journal aggregation time", + with_settings: { journal_aggregation_time_minutes: 0 } do + it "keeps the label because an earlier journal still references it" do + set_labels([label.id]) + set_labels([]) + + expect(Journal::LabelJournal.where(label:)).to exist + expect { described_class.perform_now }.not_to change(Label, :count) + end + end + end +end From 044c882be77ffb3b47b8a19e07c2ddce1221346c Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:19:25 +0300 Subject: [PATCH 05/13] Hold label rows while a work package writes its labelings Keeps the nightly cleanup from deleting a label between the existence check and the labeling insert of the same save. See COMMS-1050. --- app/models/concerns/labelable.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb index 9ecd958b9f67..0841ebb27d2f 100644 --- a/app/models/concerns/labelable.rb +++ b/app/models/concerns/labelable.rb @@ -73,7 +73,9 @@ def normalize_label_ids(new_labels) def validate_label_id_replacements return unless override_labels? - missing = label_id_replacements - Label.where(id: label_id_replacements).pluck(:id) + # Holds the label rows until the labelings are written, so the nightly cleanup + # cannot delete a label this save is about to use. + missing = label_id_replacements - Label.where(id: label_id_replacements).lock("FOR KEY SHARE").pluck(:id) errors.add(:labels, :does_not_exist) if missing.any? end From fb4246919b0ee06436d0a201150fb911712a3d61 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:23:06 +0300 Subject: [PATCH 06/13] Lock candidate labels before the nightly cleanup deletes them A work package save holds the labels it is about to use with a key share lock. Taking the rows for update and skipping locked ones means the job never deletes a label a save is attaching, and a save that starts after the job has locked a label fails its validation instead of the insert. See COMMS-1050. --- app/models/concerns/labelable.rb | 3 +-- app/workers/labels/cleanup_unused_inline_job.rb | 11 ++++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb index 0841ebb27d2f..72b2835c7670 100644 --- a/app/models/concerns/labelable.rb +++ b/app/models/concerns/labelable.rb @@ -73,8 +73,7 @@ def normalize_label_ids(new_labels) def validate_label_id_replacements return unless override_labels? - # Holds the label rows until the labelings are written, so the nightly cleanup - # cannot delete a label this save is about to use. + # Locks the label rows so a concurrent cleanup either skips them or fails this validation instead of the labeling insert. missing = label_id_replacements - Label.where(id: label_id_replacements).lock("FOR KEY SHARE").pluck(:id) errors.add(:labels, :does_not_exist) if missing.any? diff --git a/app/workers/labels/cleanup_unused_inline_job.rb b/app/workers/labels/cleanup_unused_inline_job.rb index e88645d8aa7b..bf761b38c3ef 100644 --- a/app/workers/labels/cleanup_unused_inline_job.rb +++ b/app/workers/labels/cleanup_unused_inline_job.rb @@ -33,6 +33,15 @@ class Labels::CleanupUnusedInlineJob < ApplicationJob queue_with_priority :low def perform - Label.created_inline.never_used.where(created_at: ..GRACE_PERIOD.ago).delete_all + Label.transaction do + ids = candidates.lock("FOR UPDATE SKIP LOCKED").pluck(:id) + Label.where(id: ids).delete_all if ids.any? + end + end + + private + + def candidates + Label.created_inline.never_used.where(created_at: ..GRACE_PERIOD.ago) end end From b83ec017696779b23356931fbffde096e55df33a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 21 Oct 2026 10:23:23 +0300 Subject: [PATCH 07/13] Tighten the label cleanup job spec Asserts the schedule itself and drops an example the model spec already covers. --- spec/workers/labels/cleanup_unused_inline_job_spec.rb | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/spec/workers/labels/cleanup_unused_inline_job_spec.rb b/spec/workers/labels/cleanup_unused_inline_job_spec.rb index ffad4fef9af7..cd32eff54607 100644 --- a/spec/workers/labels/cleanup_unused_inline_job_spec.rb +++ b/spec/workers/labels/cleanup_unused_inline_job_spec.rb @@ -33,7 +33,8 @@ let(:label) { create(:label, :created_inline, created_at: 2.days.ago) } it "is scheduled nightly" do - expect(Rails.application.config.good_job.cron).to have_key(:"Labels::CleanupUnusedInlineJob") + expect(Rails.application.config.good_job.cron[:"Labels::CleanupUnusedInlineJob"]) + .to include(cron: "37 2 * * *", class: described_class.name) end it "deletes an inline label older than the grace period that was never used" do @@ -57,12 +58,6 @@ expect(Label.exists?(curated.id)).to be(true) end - it "keeps an inline label that currently labels a work package" do - create(:labeling, label:) - - expect { described_class.perform_now }.not_to change(Label, :count) - end - it "keeps an inline label that only a journal snapshot references" do create(:journal_label_journal, journal: create(:work_package).journals.last, label:) From a704807525c39e458ff49152c031fc1c56e9490f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 09:46:17 +0300 Subject: [PATCH 08/13] Re-check label usage under the cleanup lock before deleting The candidate scan reads usage from the SELECT snapshot; a work package save that commits during that scan leaves the label unlocked but used. Re-applying never_used in the DELETE sees that labeling. --- app/workers/labels/cleanup_unused_inline_job.rb | 8 ++++++-- spec/workers/labels/cleanup_unused_inline_job_spec.rb | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/workers/labels/cleanup_unused_inline_job.rb b/app/workers/labels/cleanup_unused_inline_job.rb index bf761b38c3ef..e7ecc6e60535 100644 --- a/app/workers/labels/cleanup_unused_inline_job.rb +++ b/app/workers/labels/cleanup_unused_inline_job.rb @@ -34,13 +34,17 @@ class Labels::CleanupUnusedInlineJob < ApplicationJob def perform Label.transaction do - ids = candidates.lock("FOR UPDATE SKIP LOCKED").pluck(:id) - Label.where(id: ids).delete_all if ids.any? + ids = candidate_ids + Label.never_used.where(id: ids).delete_all if ids.any? end end private + def candidate_ids + candidates.lock("FOR UPDATE SKIP LOCKED").pluck(:id) + end + def candidates Label.created_inline.never_used.where(created_at: ..GRACE_PERIOD.ago) end diff --git a/spec/workers/labels/cleanup_unused_inline_job_spec.rb b/spec/workers/labels/cleanup_unused_inline_job_spec.rb index cd32eff54607..490618cc5eb6 100644 --- a/spec/workers/labels/cleanup_unused_inline_job_spec.rb +++ b/spec/workers/labels/cleanup_unused_inline_job_spec.rb @@ -64,6 +64,17 @@ expect { described_class.perform_now }.not_to change(Label, :count) end + it "keeps a label that gets used between candidate selection and deletion" do + label + job = described_class.new + allow(job).to receive(:candidate_ids).and_wrap_original do |original| + original.call.tap { create(:labeling, label:) } + end + + expect { job.perform_now }.not_to change(Label, :count) + expect(Labeling.where(label:)).to exist + end + describe "labels added to and removed from a work package" do shared_let(:user) { create(:admin) } shared_let(:work_package) do From 1e4eeba80d5bd0b8ec41fc03dd1fb8b511a5dabc Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 09:46:20 +0300 Subject: [PATCH 09/13] Anti-join unused labels with NOT EXISTS Postgres cannot turn NOT IN (subselect) into an index-backed anti-join and label_journals grows with every work package journal version. --- app/models/label.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index ddeb620a84ca..456c958caddc 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -61,8 +61,8 @@ class Label < ApplicationRecord scope :created_inline, -> { where(created_inline: true) } scope :never_used, -> { - where.not(id: Labeling.select(:label_id)) - .where.not(id: Journal::LabelJournal.select(:label_id)) + where("NOT EXISTS (SELECT 1 FROM labelings WHERE labelings.label_id = labels.id)") + .where("NOT EXISTS (SELECT 1 FROM label_journals WHERE label_journals.label_id = labels.id)") } normalizes :name, with: -> { it.squish } From 67e44f9bfcae2c2f654e9224febce8dc372d1c03 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 09:46:22 +0300 Subject: [PATCH 10/13] Shorten the label lock comment and note the cleanup cron time --- app/models/concerns/labelable.rb | 2 +- config/initializers/cronjobs.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb index 72b2835c7670..7522a9be4a1e 100644 --- a/app/models/concerns/labelable.rb +++ b/app/models/concerns/labelable.rb @@ -73,7 +73,7 @@ def normalize_label_ids(new_labels) def validate_label_id_replacements return unless override_labels? - # Locks the label rows so a concurrent cleanup either skips them or fails this validation instead of the labeling insert. + # KEY SHARE keeps the cleanup job from deleting these labels until this save commits. missing = label_id_replacements - Label.where(id: label_id_replacements).lock("FOR KEY SHARE").pluck(:id) errors.add(:labels, :does_not_exist) if missing.any? diff --git a/config/initializers/cronjobs.rb b/config/initializers/cronjobs.rb index 55e5725374fc..41e077c0b004 100644 --- a/config/initializers/cronjobs.rb +++ b/config/initializers/cronjobs.rb @@ -80,7 +80,7 @@ class: AI::TextTransformRuns::CleanupJob.name }, "Labels::CleanupUnusedInlineJob": { - cron: "37 2 * * *", + cron: "37 2 * * *", # runs at 2:37 nightly class: Labels::CleanupUnusedInlineJob.name } } From 1d638bf6ddbcef61cf98fb1a227562a3643f8ac2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 10:56:55 +0300 Subject: [PATCH 11/13] Restart the inline label grace period whenever it is handed out again Keying the cleanup on created_at let a stale unused inline label be returned by find-or-create and deleted before the work package save. --- app/services/labels/find_or_create_service.rb | 9 ++++---- .../labels/cleanup_unused_inline_job.rb | 2 +- .../labels/find_or_create_service_spec.rb | 22 +++++++++++++++++++ .../labels/cleanup_unused_inline_job_spec.rb | 11 ++++++++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index 80b825316930..7137ac3ad683 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -38,7 +38,7 @@ def initialize(user:) def call(name:) existing = Label.named(name).first - return ServiceResult.success(result: existing) if existing + return found(existing) if existing create(name) end @@ -49,11 +49,12 @@ def create(name) call = CreateService.new(user:).call(name:, created_inline: true) return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken) - existing(name) + found(Label.named(name).first!) end - def existing(name) - ServiceResult.success(result: Label.named(name).first!) + def found(label) + label.touch if label.created_inline? + ServiceResult.success(result: label) end end end diff --git a/app/workers/labels/cleanup_unused_inline_job.rb b/app/workers/labels/cleanup_unused_inline_job.rb index e7ecc6e60535..903a111fa7fd 100644 --- a/app/workers/labels/cleanup_unused_inline_job.rb +++ b/app/workers/labels/cleanup_unused_inline_job.rb @@ -46,6 +46,6 @@ def candidate_ids end def candidates - Label.created_inline.never_used.where(created_at: ..GRACE_PERIOD.ago) + Label.created_inline.never_used.where(updated_at: ..GRACE_PERIOD.ago) end end diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb index acab296b6c20..8645b5d3daaa 100644 --- a/spec/services/labels/find_or_create_service_spec.rb +++ b/spec/services/labels/find_or_create_service_spec.rb @@ -73,6 +73,28 @@ end end + context "when an inline-created label with that name exists" do + shared_let(:existing) { create(:label, :created_inline, name: "bug", created_at: 2.days.ago, updated_at: 2.days.ago) } + + let(:name) { "Bug" } + + it "touches an existing inline label so the cleanup grace period restarts" do + freeze_time + + expect { call }.to change { existing.reload.updated_at }.to(Time.current) + end + end + + context "when an admin-created label with that name exists" do + shared_let(:existing) { create(:label, name: "bug", updated_at: 2.days.ago) } + + let(:name) { "Bug" } + + it "leaves an admin-created label untouched" do + expect { call }.not_to change { existing.reload.updated_at } + end + end + context "when another request creates the label between lookup and validation" do let(:name) { "Bug" } diff --git a/spec/workers/labels/cleanup_unused_inline_job_spec.rb b/spec/workers/labels/cleanup_unused_inline_job_spec.rb index 490618cc5eb6..4c2759b5138c 100644 --- a/spec/workers/labels/cleanup_unused_inline_job_spec.rb +++ b/spec/workers/labels/cleanup_unused_inline_job_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" RSpec.describe Labels::CleanupUnusedInlineJob, type: :job do - let(:label) { create(:label, :created_inline, created_at: 2.days.ago) } + let(:label) { create(:label, :created_inline, created_at: 2.days.ago, updated_at: 2.days.ago) } it "is scheduled nightly" do expect(Rails.application.config.good_job.cron[:"Labels::CleanupUnusedInlineJob"]) @@ -51,8 +51,15 @@ expect(Label.exists?(young.id)).to be(true) end + it "keeps an inline label that was handed out again within the grace period" do + recent = create(:label, :created_inline, created_at: 2.days.ago, updated_at: 1.hour.ago) + + expect { described_class.perform_now }.not_to change(Label, :count) + expect(Label.exists?(recent.id)).to be(true) + end + it "keeps an admin-created label that was never used" do - curated = create(:label, created_at: 2.days.ago) + curated = create(:label, created_at: 2.days.ago, updated_at: 2.days.ago) expect { described_class.perform_now }.not_to change(Label, :count) expect(Label.exists?(curated.id)).to be(true) From bc5d72ae24e264019ef3884babf3a7b03050a816 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 10:59:15 +0300 Subject: [PATCH 12/13] Keep an inline label a cleanup candidate when an edit changes nothing A no-op re-submit of the admin form or an empty API PATCH is not curation and must not make an unused typo label immortal. --- app/services/labels/set_attributes_service.rb | 10 +++------- spec/services/labels/update_service_spec.rb | 7 +++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/services/labels/set_attributes_service.rb b/app/services/labels/set_attributes_service.rb index 87c8f7728fe4..5a8fee4d847d 100644 --- a/app/services/labels/set_attributes_service.rb +++ b/app/services/labels/set_attributes_service.rb @@ -33,15 +33,11 @@ class SetAttributesService < ::BaseServices::SetAttributes def set_attributes(params) super - curate_on_edit + clear_inline_origin if model.persisted? && model.changed? end - def curate_on_edit - return unless model.persisted? - - model.change_by_system do - model.created_inline = false - end + def clear_inline_origin + model.change_by_system { model.created_inline = false } end def set_default_attributes(_params) diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb index 66b28a0f4e6d..1ffb05f35aea 100644 --- a/spec/services/labels/update_service_spec.rb +++ b/spec/services/labels/update_service_spec.rb @@ -57,6 +57,13 @@ expect(result).to be_success expect(label.reload).not_to be_created_inline end + + it "stays a cleanup candidate when nothing changes" do + result = instance.call(name: "Bug") + + expect(result).to be_success + expect(label.reload).to be_created_inline + end end context "when the new name is already taken in a different case" do From 89721861ddd58ff3baa08e789936513e8642ed0a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 23 Oct 2026 15:07:15 +0300 Subject: [PATCH 13/13] Book the inline label cleanup from label creation instead of waiting a day Product accepts a short grace period, so an inline label creation books a cleanup run 15 minutes later and the job books its own next run while unused inline labels remain. The nightly cron entry stays as a safety net. --- app/services/labels/find_or_create_service.rb | 10 ++++- .../labels/cleanup_unused_inline_job.rb | 17 ++++++- .../labels/find_or_create_service_spec.rb | 8 ++++ .../labels/cleanup_unused_inline_job_spec.rb | 44 ++++++++++++++++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index 7137ac3ad683..7421371cb058 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -47,9 +47,15 @@ def call(name:) def create(name) call = CreateService.new(user:).call(name:, created_inline: true) - return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken) - found(Label.named(name).first!) + if call.success? + CleanupUnusedInlineJob.perform_after_grace + call + elsif call.errors.symbols_for(:name).include?(:taken) + found(Label.named(name).first!) + else + call + end end def found(label) diff --git a/app/workers/labels/cleanup_unused_inline_job.rb b/app/workers/labels/cleanup_unused_inline_job.rb index 903a111fa7fd..aa9dadb182db 100644 --- a/app/workers/labels/cleanup_unused_inline_job.rb +++ b/app/workers/labels/cleanup_unused_inline_job.rb @@ -28,19 +28,34 @@ #++ class Labels::CleanupUnusedInlineJob < ApplicationJob - GRACE_PERIOD = 1.day + include JobConcurrency + + GRACE_PERIOD = 15.minutes + MIN_WAIT = 1.minute queue_with_priority :low + good_job_control_concurrency_with(enqueue_limit: 1, key: name) + + def self.perform_after_grace(from: Time.current) + set(wait_until: [from + GRACE_PERIOD, MIN_WAIT.from_now].max).perform_later + end def perform Label.transaction do ids = candidate_ids Label.never_used.where(id: ids).delete_all if ids.any? end + + schedule_next_run end private + def schedule_next_run + oldest = Label.created_inline.never_used.minimum(:updated_at) + self.class.perform_after_grace(from: oldest) if oldest + end + def candidate_ids candidates.lock("FOR UPDATE SKIP LOCKED").pluck(:id) end diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb index 8645b5d3daaa..b3eef44bf1f7 100644 --- a/spec/services/labels/find_or_create_service_spec.rb +++ b/spec/services/labels/find_or_create_service_spec.rb @@ -45,6 +45,10 @@ expect(call.result).to be_previously_new_record expect(call.result).to be_created_inline end + + it "books a cleanup run" do + expect { call }.to have_enqueued_job(Labels::CleanupUnusedInlineJob) + end end context "when a label with the same name in different casing exists" do @@ -78,6 +82,10 @@ let(:name) { "Bug" } + it "books no cleanup run" do + expect { call }.not_to have_enqueued_job(Labels::CleanupUnusedInlineJob) + end + it "touches an existing inline label so the cleanup grace period restarts" do freeze_time diff --git a/spec/workers/labels/cleanup_unused_inline_job_spec.rb b/spec/workers/labels/cleanup_unused_inline_job_spec.rb index 4c2759b5138c..5578235264a8 100644 --- a/spec/workers/labels/cleanup_unused_inline_job_spec.rb +++ b/spec/workers/labels/cleanup_unused_inline_job_spec.rb @@ -37,6 +37,21 @@ .to include(cron: "37 2 * * *", class: described_class.name) end + it "collapses repeated bookings into one pending run", with_good_job: described_class do + 2.times { described_class.perform_after_grace } + + expect(GoodJob::Job.where(concurrency_key: described_class.name).count).to eq(1) + end + + describe ".perform_after_grace" do + it "enqueues a run once the grace period has passed" do + freeze_time + + expect { described_class.perform_after_grace } + .to have_enqueued_job(described_class).at(described_class::GRACE_PERIOD.from_now) + end + end + it "deletes an inline label older than the grace period that was never used" do label @@ -45,19 +60,44 @@ end it "keeps an inline label younger than the grace period" do - young = create(:label, :created_inline, created_at: 1.hour.ago) + young = create(:label, :created_inline, created_at: 5.minutes.ago, updated_at: 5.minutes.ago) expect { described_class.perform_now }.not_to change(Label, :count) expect(Label.exists?(young.id)).to be(true) end it "keeps an inline label that was handed out again within the grace period" do - recent = create(:label, :created_inline, created_at: 2.days.ago, updated_at: 1.hour.ago) + recent = create(:label, :created_inline, created_at: 2.days.ago, updated_at: 5.minutes.ago) expect { described_class.perform_now }.not_to change(Label, :count) expect(Label.exists?(recent.id)).to be(true) end + it "books the next run for when the oldest remaining inline label leaves its grace period" do + freeze_time + create(:label, :created_inline, updated_at: 5.minutes.ago) + create(:label, :created_inline, updated_at: 10.minutes.ago) + + expect { described_class.perform_now } + .to have_enqueued_job(described_class).at(5.minutes.from_now) + end + + it "waits before retrying a label it could not lock" do + freeze_time + label + job = described_class.new + allow(job).to receive(:candidate_ids).and_return([]) + + expect { job.perform_now } + .to have_enqueued_job(described_class).at(described_class::MIN_WAIT.from_now) + end + + it "books no further run when no unused inline label remains" do + label + + expect { described_class.perform_now }.not_to have_enqueued_job(described_class) + end + it "keeps an admin-created label that was never used" do curated = create(:label, created_at: 2.days.ago, updated_at: 2.days.ago)