diff options
Diffstat (limited to 'app/models/concerns/relative_positioning.rb')
-rw-r--r-- | app/models/concerns/relative_positioning.rb | 424 |
1 files changed, 65 insertions, 359 deletions
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index d1f04609693..3cbc174536c 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -27,18 +27,7 @@ # module RelativePositioning extend ActiveSupport::Concern - - STEPS = 10 - IDEAL_DISTANCE = 2**(STEPS - 1) + 1 - - MIN_POSITION = Gitlab::Database::MIN_INT_VALUE - START_POSITION = 0 - MAX_POSITION = Gitlab::Database::MAX_INT_VALUE - - MAX_GAP = IDEAL_DISTANCE * 2 - MIN_GAP = 2 - - NoSpaceLeft = Class.new(StandardError) + include ::Gitlab::RelativePositioning class_methods do def move_nulls_to_end(objects) @@ -49,56 +38,10 @@ module RelativePositioning move_nulls(objects, at_end: false) end - # This method takes two integer values (positions) and - # calculates the position between them. The range is huge as - # the maximum integer value is 2147483647. - # - # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION]. - # - # Then we handle one of three cases: - # - If the gap is too small, we raise NoSpaceLeft - # - If the gap is larger than MAX_GAP, we place the new position at most - # IDEAL_DISTANCE from the edge of the gap. - # - otherwise we place the new position at the midpoint. - # - # The new position will always satisfy: pos_before <= midpoint <= pos_after - # - # As a precondition, the gap between pos_before and pos_after MUST be >= 2. - # If the gap is too small, NoSpaceLeft is raised. - # - # This class method should only be called by instance methods of this module, which - # include handling for minimum gap size. - # - # @raises NoSpaceLeft - # @api private - def position_between(pos_before, pos_after) - pos_before ||= MIN_POSITION - pos_after ||= MAX_POSITION - - pos_before, pos_after = [pos_before, pos_after].sort - - gap_width = pos_after - pos_before - midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min - - if gap_width < MIN_GAP - raise NoSpaceLeft - elsif gap_width > MAX_GAP - if pos_before == MIN_POSITION - pos_after - IDEAL_DISTANCE - elsif pos_after == MAX_POSITION - pos_before + IDEAL_DISTANCE - else - midpoint - end - else - midpoint - end - end - private # @api private - def gap_size(object, gaps:, at_end:, starting_from:) + def gap_size(context, gaps:, at_end:, starting_from:) total_width = IDEAL_DISTANCE * gaps size = if at_end && starting_from + total_width >= MAX_POSITION (MAX_POSITION - starting_from) / gaps @@ -108,23 +51,17 @@ module RelativePositioning IDEAL_DISTANCE end - # Shift max elements leftwards if there isn't enough space return [size, starting_from] if size >= MIN_GAP - order = at_end ? :desc : :asc - terminus = object - .send(:relative_siblings) # rubocop:disable GitlabSecurity/PublicSend - .where('relative_position IS NOT NULL') - .order(relative_position: order) - .first - if at_end - terminus.move_sequence_before(true) - max_relative_position = terminus.reset.relative_position + terminus = context.max_sibling + terminus.shift_left + max_relative_position = terminus.relative_position [[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position] else - terminus.move_sequence_after(true) - min_relative_position = terminus.reset.relative_position + terminus = context.min_sibling + terminus.shift_right + min_relative_position = terminus.relative_position [[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position] end end @@ -142,8 +79,9 @@ module RelativePositioning objects = objects.reject(&:relative_position) return 0 if objects.empty? - representative = objects.first - number_of_gaps = objects.size + 1 # 1 at left, one between each, and one at right + number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each + representative = RelativePositioning.mover.context(objects.first) + position = if at_end representative.max_relative_position else @@ -152,16 +90,21 @@ module RelativePositioning position ||= START_POSITION # If there are no positioned siblings, start from START_POSITION - gap, position = gap_size(representative, gaps: number_of_gaps, at_end: at_end, starting_from: position) - - # Raise if we could not make enough space - raise NoSpaceLeft if gap < MIN_GAP + gap = 0 + attempts = 10 # consolidate up to 10 gaps to find enough space + while gap < 1 && attempts > 0 + gap, position = gap_size(representative, gaps: number_of_gaps, at_end: at_end, starting_from: position) + attempts -= 1 + end - indexed = objects.each_with_index.to_a - starting_from = at_end ? position : position - (gap * number_of_gaps) + # Allow placing items next to each other, if we have to. + gap = 1 if gap < MIN_GAP + delta = at_end ? gap : -gap + indexed = (at_end ? objects : objects.reverse).each_with_index # Some classes are polymorphic, and not all siblings are in the same table. by_model = indexed.group_by { |pair| pair.first.class } + lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position] by_model.each do |model, pairs| model.transaction do @@ -169,7 +112,8 @@ module RelativePositioning # These are known to be integers, one from the DB, and the other # calculated by us, and thus safe to interpolate values = batch.map do |obj, i| - pos = starting_from + gap * (i + 1) + desired_pos = position + delta * (i + 1) + pos = desired_pos.clamp(lower_bound, upper_bound) obj.relative_position = pos "(#{obj.id}, #{pos})" end.join(', ') @@ -192,306 +136,68 @@ module RelativePositioning end end - def min_relative_position(&block) - calculate_relative_position('MIN', &block) - end - - def max_relative_position(&block) - calculate_relative_position('MAX', &block) - end - - def prev_relative_position(ignoring: nil) - prev_pos = nil - - if self.relative_position - prev_pos = max_relative_position do |relation| - relation = relation.id_not_in(ignoring.id) if ignoring.present? - relation.where('relative_position < ?', self.relative_position) - end - end - - prev_pos - end - - def next_relative_position(ignoring: nil) - next_pos = nil - - if self.relative_position - next_pos = min_relative_position do |relation| - relation = relation.id_not_in(ignoring.id) if ignoring.present? - relation.where('relative_position > ?', self.relative_position) - end - end - - next_pos + def self.mover + ::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION)) end def move_between(before, after) - return move_after(before) unless after - return move_before(after) unless before - - before, after = after, before if after.relative_position < before.relative_position - - pos_left = before.relative_position - pos_right = after.relative_position + before, after = [before, after].sort_by(&:relative_position) if before && after - if pos_right - pos_left < MIN_GAP - # Not enough room! Make space by shifting all previous elements to the left - # if there is enough space, else to the right - gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend - - if gap.present? - after.move_sequence_before(next_gap: gap) - pos_left -= optimum_delta_for_gap(gap) - else - before.move_sequence_after - pos_right = after.reset.relative_position - end - end - - new_position = self.class.position_between(pos_left, pos_right) - - self.relative_position = new_position + RelativePositioning.mover.move(self, before, after) + rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e + could_not_move(e) + raise e end def move_after(before = self) - pos_before = before.relative_position - pos_after = before.next_relative_position(ignoring: self) - - if pos_before == MAX_POSITION || gap_too_small?(pos_after, pos_before) - gap = before.send(:find_next_gap_after) # rubocop:disable GitlabSecurity/PublicSend - - if gap.nil? - before.move_sequence_before(true) - pos_before = before.reset.relative_position - else - before.move_sequence_after(next_gap: gap) - pos_after += optimum_delta_for_gap(gap) - end - end - - self.relative_position = self.class.position_between(pos_before, pos_after) + RelativePositioning.mover.move(self, before, nil) + rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e + could_not_move(e) + raise e end def move_before(after = self) - pos_after = after.relative_position - pos_before = after.prev_relative_position(ignoring: self) - - if pos_after == MIN_POSITION || gap_too_small?(pos_before, pos_after) - gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend - - if gap.nil? - after.move_sequence_after(true) - pos_after = after.reset.relative_position - else - after.move_sequence_before(next_gap: gap) - pos_before -= optimum_delta_for_gap(gap) - end - end - - self.relative_position = self.class.position_between(pos_before, pos_after) + RelativePositioning.mover.move(self, nil, after) + rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e + could_not_move(e) + raise e end def move_to_end - max_pos = max_relative_position - - if max_pos.nil? - self.relative_position = START_POSITION - elsif gap_too_small?(max_pos, MAX_POSITION) - max = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'DESC')).first - max.move_sequence_before(true) - max.reset - self.relative_position = self.class.position_between(max.relative_position, MAX_POSITION) - else - self.relative_position = self.class.position_between(max_pos, MAX_POSITION) - end + RelativePositioning.mover.move_to_end(self) + rescue NoSpaceLeft => e + could_not_move(e) + self.relative_position = MAX_POSITION + rescue ActiveRecord::QueryCanceled => e + could_not_move(e) + raise e end def move_to_start - min_pos = min_relative_position - - if min_pos.nil? - self.relative_position = START_POSITION - elsif gap_too_small?(min_pos, MIN_POSITION) - min = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'ASC')).first - min.move_sequence_after(true) - min.reset - self.relative_position = self.class.position_between(MIN_POSITION, min.relative_position) - else - self.relative_position = self.class.position_between(MIN_POSITION, min_pos) - end - end - - # Moves the sequence before the current item to the middle of the next gap - # For example, we have - # - # 5 . . . . . 11 12 13 14 [15] 16 . 17 - # ----------- - # - # This moves the sequence [11 12 13 14] to [8 9 10 11], so we have: - # - # 5 . . 8 9 10 11 . . . [15] 16 . 17 - # --------- - # - # Creating a gap to the left of the current item. We can understand this as - # dividing the 5 spaces between 5 and 11 into two smaller gaps of 2 and 3. - # - # If `include_self` is true, the current item will also be moved, creating a - # gap to the right of the current item: - # - # 5 . . 8 9 10 11 [14] . . . 16 . 17 - # -------------- - # - # As an optimization, the gap can be precalculated and passed to this method. - # - # @api private - # @raises NoSpaceLeft if the sequence cannot be moved - def move_sequence_before(include_self = false, next_gap: find_next_gap_before) - raise NoSpaceLeft unless next_gap.present? - - delta = optimum_delta_for_gap(next_gap) - - move_sequence(next_gap[:start], relative_position, -delta, include_self) - end - - # Moves the sequence after the current item to the middle of the next gap - # For example, we have: - # - # 8 . 10 [11] 12 13 14 15 . . . . . 21 - # ----------- - # - # This moves the sequence [12 13 14 15] to [15 16 17 18], so we have: - # - # 8 . 10 [11] . . . 15 16 17 18 . . 21 - # ----------- - # - # Creating a gap to the right of the current item. We can understand this as - # dividing the 5 spaces between 15 and 21 into two smaller gaps of 3 and 2. - # - # If `include_self` is true, the current item will also be moved, creating a - # gap to the left of the current item: - # - # 8 . 10 . . . [14] 15 16 17 18 . . 21 - # ---------------- - # - # As an optimization, the gap can be precalculated and passed to this method. - # - # @api private - # @raises NoSpaceLeft if the sequence cannot be moved - def move_sequence_after(include_self = false, next_gap: find_next_gap_after) - raise NoSpaceLeft unless next_gap.present? - - delta = optimum_delta_for_gap(next_gap) - - move_sequence(relative_position, next_gap[:start], delta, include_self) - end - - private - - def gap_too_small?(pos_a, pos_b) - return false unless pos_a && pos_b - - (pos_a - pos_b).abs < MIN_GAP - end - - # Find the first suitable gap to the left of the current position. - # - # Satisfies the relations: - # - gap[:start] <= relative_position - # - abs(gap[:start] - gap[:end]) >= MIN_GAP - # - MIN_POSITION <= gap[:start] <= MAX_POSITION - # - MIN_POSITION <= gap[:end] <= MAX_POSITION - # - # Supposing that the current item is 13, and we have a sequence of items: - # - # 1 . . . 5 . . . . 11 12 [13] 14 . . 17 - # ^---------^ - # - # Then we return: `{ start: 11, end: 5 }` - # - # Here start refers to the end of the gap closest to the current item. - def find_next_gap_before - items_with_next_pos = scoped_items - .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos') - .where('relative_position <= ?', relative_position) - .order(relative_position: :desc) - - find_next_gap(items_with_next_pos, MIN_POSITION) - end - - # Find the first suitable gap to the right of the current position. - # - # Satisfies the relations: - # - gap[:start] >= relative_position - # - abs(gap[:start] - gap[:end]) >= MIN_GAP - # - MIN_POSITION <= gap[:start] <= MAX_POSITION - # - MIN_POSITION <= gap[:end] <= MAX_POSITION - # - # Supposing the current item is 13, and that we have a sequence of items: - # - # 9 . . . [13] 14 15 . . . . 20 . . . 24 - # ^---------^ - # - # Then we return: `{ start: 15, end: 20 }` - # - # Here start refers to the end of the gap closest to the current item. - def find_next_gap_after - items_with_next_pos = scoped_items - .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos') - .where('relative_position >= ?', relative_position) - .order(:relative_position) - - find_next_gap(items_with_next_pos, MAX_POSITION) - end - - def find_next_gap(items_with_next_pos, end_is_nil) - gap = self.class - .from(items_with_next_pos, :items) - .where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP) - .limit(1) - .pluck(:pos, :next_pos) - .first - - return if gap.nil? || gap.first == end_is_nil - - { start: gap.first, end: gap.second || end_is_nil } - end - - def optimum_delta_for_gap(gap) - delta = ((gap[:start] - gap[:end]) / 2.0).abs.ceil - - [delta, IDEAL_DISTANCE].min - end - - def move_sequence(start_pos, end_pos, delta, include_self = false) - relation = include_self ? scoped_items : relative_siblings - + RelativePositioning.mover.move_to_start(self) + rescue NoSpaceLeft => e + could_not_move(e) + self.relative_position = MIN_POSITION + rescue ActiveRecord::QueryCanceled => e + could_not_move(e) + raise e + end + + # This method is used during rebalancing - override it to customise the update + # logic: + def update_relative_siblings(relation, range, delta) relation - .where('relative_position BETWEEN ? AND ?', start_pos, end_pos) + .where(relative_position: range) .update_all("relative_position = relative_position + #{delta}") end - def calculate_relative_position(calculation) - # When calculating across projects, this is much more efficient than - # MAX(relative_position) without the GROUP BY, due to index usage: - # https://gitlab.com/gitlab-org/gitlab-foss/issues/54276#note_119340977 - relation = scoped_items - .order(Gitlab::Database.nulls_last_order('position', 'DESC')) - .group(self.class.relative_positioning_parent_column) - .limit(1) - - relation = yield relation if block_given? - - relation - .pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position")) - .first&.last - end - - def relative_siblings(relation = scoped_items) - relation.id_not_in(id) + # This method is used to exclude the current self (or another object) + # from a relation. Customize this if `id <> :id` is not sufficient + def exclude_self(relation, excluded: self) + relation.id_not_in(excluded.id) end - def scoped_items - self.class.relative_positioning_query_base(self) + # Override if you want to be notified of failures to move + def could_not_move(exception) end end |