Project

General

Profile

Bug #15197 » cache_store.rb

 
1
require "dbm"
2
require "json"
3
require "timeout"
4

    
5
#
6
# `CacheStoreDatabase` acts as an interface to a persistent storage mechanism
7
# residing in the `HOMEBREW_CACHE`
8
#
9
class CacheStoreDatabase
10
  # Yields the cache store database.
11
  # Closes the database after use if it has been loaded.
12
  #
13
  # @param  [Symbol] type
14
  # @yield  [CacheStoreDatabase] self
15
  def self.use(type)
16
    database = CacheStoreDatabase.new(type)
17
    return_value = yield(database)
18
    database.close_if_open!
19
    return_value
20
  end
21

    
22
  # Sets a value in the underlying database (and creates it if necessary).
23
  def set(key, value)
24
    db[key] = value
25
  end
26

    
27
  # Gets a value from the underlying database (if it already exists).
28
  def get(key)
29
    return unless created?
30

    
31
    db[key]
32
  end
33

    
34
  # Gets a value from the underlying database (if it already exists).
35
  def delete(key)
36
    return unless created?
37

    
38
    db.delete(key)
39
  end
40

    
41
  # Closes the underlying database (if it created and open).
42
  def close_if_open!
43
    @db&.close
44
  end
45

    
46
  # Returns `true` if the cache file has been created for the given `@type`
47
  #
48
  # @return [Boolean]
49
  def created?
50
    cache_path.exist?
51
  end
52

    
53
  private
54

    
55
  # The mode of any created files will be 0664 (that is, readable and writable
56
  # by the owner and the group, and readable by everyone else). Files created
57
  # will also be modified by the process' umask value at the time of creation:
58
  #   https://docs.oracle.com/cd/E17276_01/html/api_reference/C/envopen.html
59
  DATABASE_MODE = 0664
60

    
61
  # Spend 5 seconds trying to read the DBM file. If it takes longer than this it
62
  # has likely hung or segfaulted.
63
  DBM_TEST_READ_TIMEOUT = 5
64

    
65
  # Lazily loaded database in read/write mode. If this method is called, a
66
  # database file with be created in the `HOMEBREW_CACHE` with name
67
  # corresponding to the `@type` instance variable
68
  #
69
  # @return [DBM] db
70
  def db
71
    # DBM::WRCREAT: Creates the database if it does not already exist
72
    @db ||= begin
73
      HOMEBREW_CACHE.mkpath
74
      if created?
75
        dbm_test_read_cmd = SystemCommand.new(
76
          ENV["HOMEBREW_RUBY_PATH"],
77
          args: [
78
            "-rdbm",
79
            "-e",
80
            "DBM.open('#{dbm_file_path}', #{DATABASE_MODE}, DBM::READER).size",
81
          ],
82
          print_stderr: false,
83
          must_succeed: true,
84
        )
85
        dbm_test_read_success = begin
86
          Timeout.timeout(DBM_TEST_READ_TIMEOUT) do
87
            dbm_test_read_cmd.run!
88
            true
89
          end
90
        rescue ErrorDuringExecution, Timeout::Error
91
          odebug "Failed to read #{dbm_file_path}!"
92
          begin
93
            Process.kill(:KILL, dbm_test_read_cmd.pid)
94
          rescue Errno::ESRCH
95
            # Process has already terminated.
96
            nil
97
          end
98
          false
99
        end
100
        Utils::Analytics.report_event("dbm_test_read", dbm_test_read_success.to_s)
101
        cache_path.delete unless dbm_test_read_success
102
      end
103
      DBM.open(dbm_file_path, DATABASE_MODE, DBM::WRCREAT)
104
    end
105
  end
106

    
107
  # Creates a CacheStoreDatabase
108
  #
109
  # @param  [Symbol] type
110
  # @return [nil]
111
  def initialize(type)
112
    @type = type
113
  end
114

    
115
  # `DBM` appends `.db` file extension to the path provided, which is why it's
116
  # not included
117
  #
118
  # @return [String]
119
  def dbm_file_path
120
    "#{HOMEBREW_CACHE}/#{@type}"
121
  end
122

    
123
  # The path where the database resides in the `HOMEBREW_CACHE` for the given
124
  # `@type`
125
  #
126
  # @return [String]
127
  def cache_path
128
    Pathname("#{dbm_file_path}.db")
129
  end
130
end
131

    
132
#
133
# `CacheStore` provides methods to mutate and fetch data from a persistent
134
# storage mechanism
135
#
136
class CacheStore
137
  # @param  [CacheStoreDatabase] database
138
  # @return [nil]
139
  def initialize(database)
140
    @database = database
141
  end
142

    
143
  # Inserts new values or updates existing cached values to persistent storage
144
  # mechanism
145
  #
146
  # @abstract
147
  def update!(*)
148
    raise NotImplementedError
149
  end
150

    
151
  # Fetches cached values in persistent storage according to the type of data
152
  # stored
153
  #
154
  # @abstract
155
  def fetch_type(*)
156
    raise NotImplementedError
157
  end
158

    
159
  # Deletes data from the cache based on a condition defined in a concrete class
160
  #
161
  # @abstract
162
  def flush_cache!
163
    raise NotImplementedError
164
  end
165

    
166
  protected
167

    
168
  # @return [CacheStoreDatabase]
169
  attr_reader :database
170

    
171
  # DBM stores ruby objects as a ruby `String`. Hence, when fetching the data,
172
  # to convert the ruby string back into a ruby `Hash`, the string is converted
173
  # into a JSON compatible string in `ruby_hash_to_json_string`, where it may
174
  # later be parsed by `JSON.parse` in the `json_string_to_ruby_hash` method
175
  #
176
  # @param  [Hash] ruby `Hash` to be converted to `JSON` string
177
  # @return [String]
178
  def ruby_hash_to_json_string(hash)
179
    hash.to_json
180
  end
181

    
182
  # @param  [String] `JSON` string to be converted to ruby `Hash`
183
  # @return [Hash]
184
  def json_string_to_ruby_hash(string)
185
    JSON.parse(string)
186
  end
187
end