+
diff --git a/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js b/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js
index 5512cc2c896..83e40405389 100644
--- a/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js
+++ b/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js
@@ -26,20 +26,3 @@ export const PANEL_POPOVER_DELAY = {
};
export const CURSOR_GRABBING_CLASS = '!gl-cursor-grabbing';
-
-export const NEW_DASHBOARD_SLUG = 'new';
-
-export const CATEGORY_SINGLE_STATS = 'singleStats';
-export const CATEGORY_TABLES = 'tables';
-export const CATEGORY_CHARTS = 'charts';
-
-export const DASHBOARD_STATUS_BETA = 'beta';
-export const DASHBOARD_STATUS_EXPERIMENT = 'experiment';
-
-export const DASHBOARD_SCHEMA_VERSION = '2';
-export const VISUALIZATION_TYPE_DATA_TABLE = 'DataTable';
-export const VISUALIZATION_TYPE_LINE_CHART = 'LineChart';
-export const VISUALIZATION_TYPE_COLUMN_CHART = 'ColumnChart';
-export const VISUALIZATION_TYPE_SINGLE_STAT = 'SingleStat';
-
-export const EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER = 'user_viewed_dashboard_designer';
diff --git a/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue b/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
index d6122729003..740807f2c73 100644
--- a/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
+++ b/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
@@ -8,14 +8,14 @@ import { s__, __ } from '~/locale';
import { InternalEvents } from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import { EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER } from './constants';
-import GridstackWrapper from './gridstack_wrapper.vue';
-import AvailableVisualizationsDrawer from './dashboard_editor/available_visualizations_drawer.vue';
+import { EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER } from 'ee/analytics/analytics_dashboards/constants';
import {
getDashboardConfig,
availableVisualizationsValidator,
createNewVisualizationPanel,
-} from './utils';
+} from 'ee/analytics/analytics_dashboards/utils';
+import GridstackWrapper from './gridstack_wrapper.vue';
+import AvailableVisualizationsDrawer from './dashboard_editor/available_visualizations_drawer.vue';
export default {
name: 'CustomizableDashboard',
diff --git a/app/assets/javascripts/vue_shared/components/customizable_dashboard/dashboard_editor/available_visualizations_drawer.vue b/app/assets/javascripts/vue_shared/components/customizable_dashboard/dashboard_editor/available_visualizations_drawer.vue
index 55a5e5b6f17..04b548073af 100644
--- a/app/assets/javascripts/vue_shared/components/customizable_dashboard/dashboard_editor/available_visualizations_drawer.vue
+++ b/app/assets/javascripts/vue_shared/components/customizable_dashboard/dashboard_editor/available_visualizations_drawer.vue
@@ -5,8 +5,12 @@ import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { toggleArrayItem } from '~/lib/utils/array_utility';
-import { getVisualizationCategory } from '../utils';
-import { CATEGORY_SINGLE_STATS, CATEGORY_CHARTS, CATEGORY_TABLES } from '../constants';
+import {
+ CATEGORY_SINGLE_STATS,
+ CATEGORY_CHARTS,
+ CATEGORY_TABLES,
+} from 'ee/analytics/analytics_dashboards/constants';
+import { getVisualizationCategory } from 'ee/analytics/analytics_dashboards/utils';
export default {
name: 'AvailableVisualizatiosnDrawer',
diff --git a/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js b/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js
index c88875a71d5..f64c9430b4d 100644
--- a/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js
+++ b/app/assets/javascripts/vue_shared/components/customizable_dashboard/utils.js
@@ -1,17 +1,4 @@
import isEmpty from 'lodash/isEmpty';
-import uniqueId from 'lodash/uniqueId';
-
-import { humanize } from '~/lib/utils/text_utility';
-import { cloneWithoutReferences } from '~/lib/utils/common_utils';
-
-import {
- DASHBOARD_SCHEMA_VERSION,
- VISUALIZATION_TYPE_DATA_TABLE,
- VISUALIZATION_TYPE_SINGLE_STAT,
- CATEGORY_SINGLE_STATS,
- CATEGORY_CHARTS,
- CATEGORY_TABLES,
-} from './constants';
export const isEmptyPanelData = (visualizationType, data) => {
if (visualizationType === 'SingleStat') {
@@ -22,52 +9,6 @@ export const isEmptyPanelData = (visualizationType, data) => {
return isEmpty(data);
};
-/**
- * Validator for the availableVisualizations property
- */
-export const availableVisualizationsValidator = ({ loading, hasError, visualizations }) => {
- return (
- typeof loading === 'boolean' && typeof hasError === 'boolean' && Array.isArray(visualizations)
- );
-};
-
-/**
- * Get the category key for visualizations by their type. Default is "charts".
- */
-export const getVisualizationCategory = (visualization) => {
- if (visualization.type === VISUALIZATION_TYPE_SINGLE_STAT) {
- return CATEGORY_SINGLE_STATS;
- }
- if (visualization.type === VISUALIZATION_TYPE_DATA_TABLE) {
- return CATEGORY_TABLES;
- }
- return CATEGORY_CHARTS;
-};
-
-export const getUniquePanelId = () => uniqueId('panel-');
-
-/**
- * Maps a full hydrated dashboard (including GraphQL __typenames, and full visualization definitions) into a slimmed down version that complies with the dashboard schema definition
- */
-export const getDashboardConfig = (hydratedDashboard) => {
- const { __typename: dashboardTypename, userDefined, slug, ...dashboardRest } = hydratedDashboard;
-
- return {
- ...dashboardRest,
- version: DASHBOARD_SCHEMA_VERSION,
- panels: hydratedDashboard.panels.map((panel) => {
- const { __typename: panelTypename, id, ...panelRest } = panel;
- const { __typename: visualizationTypename, ...visualizationRest } = panel.visualization;
-
- return {
- ...panelRest,
- queryOverrides: panel.queryOverrides ?? {},
- visualization: visualizationRest,
- };
- }),
- };
-};
-
const filterUndefinedValues = (obj) => {
// eslint-disable-next-line no-unused-vars
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined));
@@ -98,18 +39,6 @@ export const parsePanelToGridItem = ({
},
});
-export const createNewVisualizationPanel = (visualization) => ({
- id: getUniquePanelId(),
- title: humanize(visualization.slug),
- gridAttributes: {
- width: 4,
- height: 3,
- },
- queryOverrides: {},
- options: {},
- visualization: cloneWithoutReferences({ ...visualization, errors: null }),
-});
-
export const dashboardConfigValidator = (config) => {
if (config.panels) {
if (!Array.isArray(config.panels)) return false;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 60b127fc22b..4f1f59d6d54 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -208,6 +208,11 @@
}
}
+ // Fix border bleed.
+ .gl-table-shadow:not(:has(+ .glql-load-more)) {
+ clip-path: inset(0 round 0 0 1px 1px);
+ }
+
.gl-table-shadow {
overflow-x: auto;
overflow-y: hidden;
diff --git a/app/graphql/mutations/work_items/bulk_update.rb b/app/graphql/mutations/work_items/bulk_update.rb
index 8404175016f..0ea40474fbd 100644
--- a/app/graphql/mutations/work_items/bulk_update.rb
+++ b/app/graphql/mutations/work_items/bulk_update.rb
@@ -111,9 +111,9 @@ module Mutations
end
end
- def find_parent_by_full_path(full_path)
+ def find_parent_by_full_path(full_path, model = ::Project)
# Note: Group support is added in the EE module. For CE, we only support bulk edit for projects
- ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(::Project, full_path).find.sync
+ ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find.sync
end
end
end
diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb
index 24d8403686b..c1da8cdbcb6 100644
--- a/lib/gitlab/application_rate_limiter.rb
+++ b/lib/gitlab/application_rate_limiter.rb
@@ -277,6 +277,8 @@ module Gitlab
strategy.increment(cache_key, expiry)
end
+ return false if value.nil?
+
report_metrics(key, value, threshold_value, peek)
value > threshold_value
diff --git a/lib/gitlab/application_rate_limiter/base_strategy.rb b/lib/gitlab/application_rate_limiter/base_strategy.rb
index b97770c0524..50f52777cfd 100644
--- a/lib/gitlab/application_rate_limiter/base_strategy.rb
+++ b/lib/gitlab/application_rate_limiter/base_strategy.rb
@@ -17,7 +17,7 @@ module Gitlab
private
def with_redis(&block)
- ::Gitlab::Redis::RateLimiting.with(&block) # rubocop: disable CodeReuse/ActiveRecord
+ ::Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
end
end
diff --git a/lib/gitlab/circuit_breaker/store.rb b/lib/gitlab/circuit_breaker/store.rb
index 0ba4f08d5e1..3ca59d3b090 100644
--- a/lib/gitlab/circuit_breaker/store.rb
+++ b/lib/gitlab/circuit_breaker/store.rb
@@ -40,11 +40,7 @@ module Gitlab
private
def with(&block)
- Gitlab::Redis::RateLimiting.with(&block)
- rescue ::Redis::BaseConnectionError
- # Do not raise an error if we cannot connect to Redis. If
- # Redis::RateLimiting is unavailable it should not take the site down.
- nil
+ Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
end
end
diff --git a/lib/gitlab/rack_attack/store.rb b/lib/gitlab/rack_attack/store.rb
index e4a1b022c32..8192147210a 100644
--- a/lib/gitlab/rack_attack/store.rb
+++ b/lib/gitlab/rack_attack/store.rb
@@ -38,15 +38,7 @@ module Gitlab
private
def with(&block)
- # rubocop: disable CodeReuse/ActiveRecord
- Gitlab::Redis::RateLimiting.with(&block)
- # rubocop: enable CodeReuse/ActiveRecord
- rescue ::Redis::BaseConnectionError
- # Following the example of
- # https://github.com/rack/rack-attack/blob/v6.6.1/lib/rack/attack/store_proxy/redis_proxy.rb#L61-L65,
- # do not raise an error if we cannot connect to Redis. If
- # Redis::RateLimiting is unavailable it should not take the site down.
- nil
+ Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
def namespace(key)
diff --git a/lib/gitlab/redis/rate_limiting.rb b/lib/gitlab/redis/rate_limiting.rb
index 30ec44b748d..e407147aa9d 100644
--- a/lib/gitlab/redis/rate_limiting.rb
+++ b/lib/gitlab/redis/rate_limiting.rb
@@ -8,6 +8,13 @@ module Gitlab
def config_fallback
Cache
end
+
+ # Rescue Redis errors so we do not take the site down when the rate limiting instance is down
+ def with_suppressed_errors(&block)
+ with(&block)
+ rescue ::Redis::BaseError, ::RedisClient::Error => e
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
+ end
end
end
end
diff --git a/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js b/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js
index 151c9a80475..567fede0436 100644
--- a/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js
+++ b/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js
@@ -11,7 +11,7 @@ import AvailableVisualizationsDrawer from '~/vue_shared/components/customizable_
import {
EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER,
DASHBOARD_SCHEMA_VERSION,
-} from '~/vue_shared/components/customizable_dashboard/constants';
+} from 'ee/analytics/analytics_dashboards/constants';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { stubComponent } from 'helpers/stub_component';
import { trimText } from 'helpers/text_helper';
diff --git a/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js b/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js
index 2843621b111..3abe3accf0f 100644
--- a/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js
+++ b/spec/frontend/vue_shared/components/customizable_dashboard/gridstack_wrapper_spec.js
@@ -10,12 +10,10 @@ import {
GRIDSTACK_CELL_HEIGHT,
GRIDSTACK_MIN_ROW,
} from '~/vue_shared/components/customizable_dashboard/constants';
+import { createNewVisualizationPanel } from 'ee/analytics/analytics_dashboards/utils';
import { loadCSSFile } from '~/lib/utils/css_utils';
import waitForPromises from 'helpers/wait_for_promises';
-import {
- parsePanelToGridItem,
- createNewVisualizationPanel,
-} from '~/vue_shared/components/customizable_dashboard/utils';
+import { parsePanelToGridItem } from '~/vue_shared/components/customizable_dashboard/utils';
import { dashboard, builtinDashboard } from './mock_data';
const mockGridSetStatic = jest.fn();
diff --git a/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js b/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js
index 63c08920b54..fa6dec56fb6 100644
--- a/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js
+++ b/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js
@@ -1,4 +1,4 @@
-import { getUniquePanelId } from '~/vue_shared/components/customizable_dashboard/utils';
+import { getUniquePanelId } from 'ee/analytics/analytics_dashboards/utils';
export const createVisualization = () => ({
version: 1,
diff --git a/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js b/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js
index baa58dea784..beeff16cb56 100644
--- a/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js
+++ b/spec/frontend/vue_shared/components/customizable_dashboard/utils_spec.js
@@ -1,39 +1,10 @@
import {
isEmptyPanelData,
- availableVisualizationsValidator,
- getDashboardConfig,
- getVisualizationCategory,
parsePanelToGridItem,
- createNewVisualizationPanel,
dashboardConfigValidator,
} from '~/vue_shared/components/customizable_dashboard/utils';
-import {
- CATEGORY_SINGLE_STATS,
- CATEGORY_CHARTS,
- CATEGORY_TABLES,
- DASHBOARD_SCHEMA_VERSION,
-} from '~/vue_shared/components/customizable_dashboard/constants';
-
-import { dashboard, mockPanel, createVisualization } from './mock_data';
-
-describe('#createNewVisualizationPanel', () => {
- it('returns the expected object', () => {
- const visualization = createVisualization();
- expect(createNewVisualizationPanel(visualization)).toMatchObject({
- visualization: {
- ...visualization,
- errors: null,
- },
- title: 'Test visualization',
- gridAttributes: {
- width: 4,
- height: 3,
- },
- options: {},
- });
- });
-});
+import { dashboard, mockPanel } from './mock_data';
describe('isEmptyPanelData', () => {
it.each`
@@ -51,84 +22,6 @@ describe('isEmptyPanelData', () => {
);
});
-describe('availableVisualizationsValidator', () => {
- it('returns true when the object contains all properties', () => {
- const result = availableVisualizationsValidator({
- loading: false,
- hasError: false,
- visualizations: [],
- });
- expect(result).toBe(true);
- });
-
- it.each([
- { visualizations: [] },
- { hasError: false },
- { loading: true },
- { loading: true, hasError: false },
- ])('returns false when the object does not contain all properties', (testCase) => {
- const result = availableVisualizationsValidator(testCase);
- expect(result).toBe(false);
- });
-});
-
-describe('getDashboardConfig', () => {
- it('maps dashboard to expected value', () => {
- const result = getDashboardConfig(dashboard);
- const visualization = createVisualization();
-
- expect(result).toMatchObject({
- id: 'analytics_overview',
- version: DASHBOARD_SCHEMA_VERSION,
- panels: [
- {
- gridAttributes: {
- height: 3,
- width: 3,
- },
- queryOverrides: {},
- title: 'Test A',
- visualization,
- },
- {
- gridAttributes: {
- height: 4,
- width: 2,
- },
- queryOverrides: {
- limit: 200,
- },
- title: 'Test B',
- visualization,
- },
- ],
- title: 'Analytics Overview',
- status: null,
- errors: null,
- });
- });
-
- ['userDefined', 'slug'].forEach((omitted) => {
- it(`omits "${omitted}" dashboard property`, () => {
- const result = getDashboardConfig(dashboard);
-
- expect(result[omitted]).not.toBeDefined();
- });
- });
-});
-
-describe('getVisualizationCategory', () => {
- it.each`
- category | type
- ${CATEGORY_SINGLE_STATS} | ${'SingleStat'}
- ${CATEGORY_TABLES} | ${'DataTable'}
- ${CATEGORY_CHARTS} | ${'LineChart'}
- ${CATEGORY_CHARTS} | ${'FooBar'}
- `('returns $category when the visualization type is $type', ({ category, type }) => {
- expect(getVisualizationCategory({ type })).toBe(category);
- });
-});
-
describe('parsePanelToGridItem', () => {
it('parses all panel configs to GridStack format', () => {
const { gridAttributes, ...rest } = mockPanel;
diff --git a/spec/lib/gitlab/application_rate_limiter_spec.rb b/spec/lib/gitlab/application_rate_limiter_spec.rb
index 07373a0730d..2a2623a5f1a 100644
--- a/spec/lib/gitlab/application_rate_limiter_spec.rb
+++ b/spec/lib/gitlab/application_rate_limiter_spec.rb
@@ -28,6 +28,21 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
end
describe '.throttled?' do
+ context 'when redis is unavailable' do
+ before do
+ broken_redis = Redis.new(
+ url: 'redis://127.0.0.0:0',
+ custom: { instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class }
+ )
+ allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
+ allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ end
+
+ it 'returns false' do
+ expect(subject.throttled?(:test_action, scope: [user])).to eq(false)
+ end
+ end
+
context 'when the key is invalid' do
context 'is provided as a Symbol' do
context 'but is not defined in the rate_limits Hash' do
diff --git a/spec/lib/gitlab/circuit_breaker/store_spec.rb b/spec/lib/gitlab/circuit_breaker/store_spec.rb
index 1b1983d4b52..1245f0d7a7a 100644
--- a/spec/lib/gitlab/circuit_breaker/store_spec.rb
+++ b/spec/lib/gitlab/circuit_breaker/store_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Gitlab::CircuitBreaker::Store, :clean_gitlab_redis_rate_limiting,
shared_examples 'reliable circuit breaker store method' do
it 'does not raise an error when Redis::BaseConnectionError is encountered' do
+ allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+
allow(Gitlab::Redis::RateLimiting)
.to receive(:with)
.and_raise(Redis::BaseConnectionError)
diff --git a/spec/lib/gitlab/rack_attack/store_spec.rb b/spec/lib/gitlab/rack_attack/store_spec.rb
index efe6f9382f9..e277e744201 100644
--- a/spec/lib/gitlab/rack_attack/store_spec.rb
+++ b/spec/lib/gitlab/rack_attack/store_spec.rb
@@ -105,6 +105,7 @@ RSpec.describe Gitlab::RackAttack::Store, :clean_gitlab_redis_rate_limiting, fea
custom: { instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class }
)
allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
+ allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
end
it { expect(subject).to eq(nil) }
diff --git a/spec/lib/gitlab/redis/rate_limiting_spec.rb b/spec/lib/gitlab/redis/rate_limiting_spec.rb
index e79c070df93..c9b8b79f0dc 100644
--- a/spec/lib/gitlab/redis/rate_limiting_spec.rb
+++ b/spec/lib/gitlab/redis/rate_limiting_spec.rb
@@ -4,4 +4,57 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::RateLimiting do
include_examples "redis_new_instance_shared_examples", 'rate_limiting', Gitlab::Redis::Cache
+
+ describe '.with_suppressed_errors' do
+ subject(:ping) { described_class.with_suppressed_errors(&:ping) }
+
+ before do
+ allow(described_class).to receive(:with).and_yield(redis)
+ end
+
+ context 'when using Redis' do
+ let(:redis) { described_class.send(:init_redis, { url: 'redis://127.0.0.0:0' }) }
+
+ it 'tracks the error and returns nil' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ .with(a_kind_of(::Redis::CannotConnectError))
+
+ expect(ping).to be_nil
+ end
+ end
+
+ context 'when using RedisCluster' do
+ let(:redis) do
+ described_class.send(:init_redis, {
+ nodes: [
+ { host: '127.0.0.0', port: 0, db: 1 },
+ { host: '127.0.0.0', port: 0, db: 2 },
+ { host: '127.0.0.0', port: 0, db: 3 }
+ ]
+ })
+ end
+
+ it 'tracks the error and returns nil' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ .with(a_kind_of(::Redis::Cluster::InitialSetupError))
+
+ expect(ping).to be_nil
+ end
+ end
+
+ context 'with a RedisClient exception' do
+ let(:redis) { instance_double(Redis) }
+
+ before do
+ allow(redis).to receive(:ping).and_raise(::RedisClient::ReadTimeoutError)
+ end
+
+ it 'tracks the error and returns nil' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ .with(a_kind_of(::RedisClient::ReadTimeoutError))
+
+ expect(ping).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb
index 9e2161b775f..b858302cc38 100644
--- a/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb
+++ b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb
@@ -50,9 +50,78 @@ RSpec.shared_examples 'it has loose foreign keys' do
model = create(factory_name) # rubocop: disable Rails/SaveBang
model_id = model.id
+ puts "## LFK Debug: Starting test for #{table_name} with model_id: #{model_id} ##"
+
+ # Check initial state
+ initial_pending_count = deleted_records.status_pending.count
+ initial_processed_count = deleted_records.status_processed.count
+ initial_total_count = deleted_records.count
+
+ puts "## LFK Debug: Initial state - Pending: #{initial_pending_count}, Processed: #{initial_processed_count}, Total: #{initial_total_count} ##"
+
+ # Check for existing records for this model_id (shouldn't exist)
+ existing_records = deleted_records.where(primary_key_value: model_id)
+ puts "## LFK Debug: Existing records for model_id #{model_id}: #{existing_records.count} ##"
+
expect { model.delete }.to change { deleted_records.count }.by(1)
- LooseForeignKeys::ProcessDeletedRecordsService.new(connection: connection).execute
+ # Check state after deletion
+ after_delete_pending = deleted_records.where(primary_key_value: model_id).status_pending.count
+ after_delete_processed = deleted_records.where(primary_key_value: model_id).status_processed.count
+ total_pending_after_delete = deleted_records.status_pending.count
+ total_processed_after_delete = deleted_records.status_processed.count
+
+ puts "## LFK Debug: After delete - Model #{model_id} Pending: #{after_delete_pending}, Processed: #{after_delete_processed} ##"
+ puts "## LFK Debug: After delete - Total Pending: #{total_pending_after_delete}, Total Processed: #{total_processed_after_delete} ##"
+
+ # Check all pending records across all tables before processing
+ all_pending_records = []
+ Gitlab::Database::LooseForeignKeys.definitions_by_table.each_key do |table|
+ fqtn = "#{connection.current_schema}.#{table}"
+ table_records = LooseForeignKeys::DeletedRecord.where(fully_qualified_table_name: fqtn).status_pending
+ all_pending_records.concat(table_records.to_a)
+ end
+
+ puts "## LFK Debug: Total pending records across all tables before processing: #{all_pending_records.count} ##"
+ puts "## LFK Debug: Tables with pending records: #{all_pending_records.group_by(&:fully_qualified_table_name).transform_values(&:count)} ##"
+
+ # Process records and capture stats
+ start_time = Time.current
+ service = LooseForeignKeys::ProcessDeletedRecordsService.new(connection: connection)
+ stats = service.execute
+ processing_time = Time.current - start_time
+
+ puts "## LFK Debug: Processing took #{processing_time} seconds ##"
+ puts "## LFK Debug: Processing stats: #{stats.inspect} ##"
+
+ # Check state after processing
+ after_process_pending = deleted_records.where(primary_key_value: model_id).status_pending.count
+ after_process_processed = deleted_records.where(primary_key_value: model_id).status_processed.count
+ total_pending_after_process = deleted_records.status_pending.count
+ total_processed_after_process = deleted_records.status_processed.count
+
+ puts "## LFK Debug: After processing - Model #{model_id} Pending: #{after_process_pending}, Processed: #{after_process_processed} ##"
+ puts "## LFK Debug: After processing - Total Pending: #{total_pending_after_process}, Total Processed: #{total_processed_after_process} ##"
+
+ # If the test is about to fail, provide additional debugging
+ if after_process_pending != 0 || after_process_processed != 1
+ puts "## LFK Debug: TEST FAILURE IMMINENT - Additional debugging ##"
+
+ # Check if our specific record exists and its status
+ our_record = deleted_records.find_by(primary_key_value: model_id)
+
+ if our_record
+ puts "## LFK Debug: Our record exists - Status: #{our_record.status}, Cleanup attempts: #{our_record.cleanup_attempts}, Consume after: #{our_record.consume_after} ##"
+ puts "## LFK Debug: Our record details: #{our_record.inspect} ##"
+ else
+ puts "## LFK Debug: Our record not found in deleted_records table ##"
+ end
+
+ # Check if there are still pending records for our table
+ table_pending = deleted_records.status_pending
+ puts "## LFK Debug: Remaining pending records for #{table_name}: #{table_pending.count} ##"
+ puts "## LFK Debug: Pending record IDs: #{table_pending.pluck(:primary_key_value)} ##" if table_pending.any?
+ end
expect(deleted_records.where(primary_key_value: model_id).status_pending.count).to eq(0)
expect(deleted_records.where(primary_key_value: model_id).status_processed.count).to eq(1)