build_trace_chunk.rb 3.72 KB
Newer Older
1
module Ci
2
  class BuildTraceChunk < ActiveRecord::Base
3
    include FastDestroyAll
4 5
    extend Gitlab::Ci::Model

6
    belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
Kamil Trzciński's avatar
Kamil Trzciński committed
7 8

    default_value_for :data_store, :redis
9
    fast_destroy_all_with :redis_delete_data, :redis_data_keys
Kamil Trzciński's avatar
Kamil Trzciński committed
10

11 12
    WriteError = Class.new(StandardError)

13
    CHUNK_SIZE = 128.kilobytes
Shinya Maeda's avatar
Shinya Maeda committed
14
    CHUNK_REDIS_TTL = 1.week
15 16 17
    WRITE_LOCK_RETRY = 100
    WRITE_LOCK_SLEEP = 1
    WRITE_LOCK_TTL = 5.minutes
Kamil Trzciński's avatar
Kamil Trzciński committed
18 19 20

    enum data_store: {
      redis: 1,
Shinya Maeda's avatar
Shinya Maeda committed
21
      db: 2
Kamil Trzciński's avatar
Kamil Trzciński committed
22 23
    }

24 25 26
    class << self
      def redis_data_key(build_id, chunk_index)
        "gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}"
27 28
      end

29
      def redis_data_keys
30 31 32
        redis.pluck(:build_id, :chunk_index).map do |data|
          redis_data_key(data.first, data.second)
        end
33 34
      end

35 36 37 38 39
      def redis_delete_data(keys)
        return if keys.empty?

        Gitlab::Redis::SharedState.with do |redis|
          redis.del(keys)
40
        end
41 42 43
      end
    end

44 45
    ##
    # Data is memoized for optimizing #size and #end_offset
Kamil Trzciński's avatar
Kamil Trzciński committed
46
    def data
47
      @data ||= get_data.to_s
Kamil Trzciński's avatar
Kamil Trzciński committed
48 49 50
    end

    def truncate(offset = 0)
51
      self.append("", offset) if offset < size
Kamil Trzciński's avatar
Kamil Trzciński committed
52 53 54
    end

    def append(new_data, offset)
55 56
      raise ArgumentError, 'Offset is out of range' if offset > data.bytesize || offset < 0
      raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize)
Kamil Trzciński's avatar
Kamil Trzciński committed
57

58
      set_data(data.byteslice(0, offset) + new_data)
Kamil Trzciński's avatar
Kamil Trzciński committed
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
    end

    def size
      data&.bytesize.to_i
    end

    def start_offset
      chunk_index * CHUNK_SIZE
    end

    def end_offset
      start_offset + size
    end

    def range
      (start_offset...end_offset)
    end

    def use_database!
78
      in_lock do
Shinya Maeda's avatar
Shinya Maeda committed
79 80
        break if db?
        break unless size > 0
Kamil Trzciński's avatar
Kamil Trzciński committed
81

82
        self.update!(raw_data: data, data_store: :db)
83 84
        key = self.class.redis_data_key(build_id, chunk_index)
        self.class.redis_delete_data([key])
85
      end
Kamil Trzciński's avatar
Kamil Trzciński committed
86 87 88 89
    end

    private

90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    def get_data
      if redis?
        redis_data
      elsif db?
        raw_data
      else
        raise 'Unsupported data store'
      end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
    end

    def set_data(value)
      raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE

      in_lock do
        if redis?
          redis_set_data(value)
        elsif db?
          self.raw_data = value
        else
          raise 'Unsupported data store'
        end

        @data = value

        save! if changed?
      end

      schedule_to_db if fullfilled?
    end

Kamil Trzciński's avatar
Kamil Trzciński committed
120 121 122
    def schedule_to_db
      return if db?

123
      BuildTraceChunkFlushToDBWorker.perform_async(id)
Kamil Trzciński's avatar
Kamil Trzciński committed
124 125 126 127 128 129 130 131
    end

    def fullfilled?
      size == CHUNK_SIZE
    end

    def redis_data
      Gitlab::Redis::SharedState.with do |redis|
132
        redis.get(self.class.redis_data_key(build_id, chunk_index))
Kamil Trzciński's avatar
Kamil Trzciński committed
133 134 135 136 137
      end
    end

    def redis_set_data(data)
      Gitlab::Redis::SharedState.with do |redis|
138
        redis.set(self.class.redis_data_key(build_id, chunk_index), data, ex: CHUNK_REDIS_TTL)
Kamil Trzciński's avatar
Kamil Trzciński committed
139 140 141
      end
    end

142
    def redis_lock_key
143
      "trace_write:#{build_id}:chunks:#{chunk_index}"
144 145 146
    end

    def in_lock
147
      lease = Gitlab::ExclusiveLease.new(redis_lock_key, timeout: WRITE_LOCK_TTL)
148 149 150 151 152
      retry_count = 0

      until uuid = lease.try_obtain
        # Keep trying until we obtain the lease. To prevent hammering Redis too
        # much we'll wait for a bit between retries.
153 154
        sleep(WRITE_LOCK_SLEEP)
        break if WRITE_LOCK_RETRY < (retry_count += 1)
155 156 157 158 159 160 161 162
      end

      raise WriteError, 'Failed to obtain write lock' unless uuid

      self.reload if self.persisted?
      return yield
    ensure
      Gitlab::ExclusiveLease.cancel(redis_lock_key, uuid)
Kamil Trzciński's avatar
Kamil Trzciński committed
163
    end
164 165
  end
end