dis 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/dis/storage.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Dis
4
4
  # = Dis Storage
5
5
  #
6
- # This is the interface for interacting with the storage layers.
6
+ # Interface for interacting with the storage layers.
7
7
  #
8
8
  # All queries are scoped by object type, which will default to the table
9
9
  # name of the model. Take care to use your own scope if you interact with
@@ -17,8 +17,13 @@ module Dis
17
17
  # one writeable, non-delayed layer must exist.
18
18
  class Storage
19
19
  class << self
20
- # Returns a hex digest for a given binary. Accepts files, strings
21
- # and Fog models.
20
+ # Returns a hex digest for a given binary. Accepts File/IO objects,
21
+ # strings, and Fog models.
22
+ #
23
+ # @param file [File, IO, String, Fog::Model] the content to digest
24
+ # @yield [hash] if a block is given, yields the hex digest
25
+ # @yieldparam hash [String] the computed SHA1 hex digest
26
+ # @return [String] the SHA1 hex digest
22
27
  def file_digest(file)
23
28
  hash = case file
24
29
  when Fog::Model
@@ -32,15 +37,25 @@ module Dis
32
37
  hash
33
38
  end
34
39
 
35
- # Exposes the layer set, which is an instance of
36
- # <tt>Dis::Layers</tt>.
40
+ # Exposes the layer set.
41
+ #
42
+ # @return [Dis::Layers]
37
43
  def layers
38
44
  @layers ||= Dis::Layers.new
39
45
  end
40
46
 
41
47
  # Changes the type of an object. Kicks off a
42
- # <tt>Dis::Jobs::ChangeType</tt> job if any delayed layers are defined.
48
+ # {Dis::Jobs::ChangeType} job if any delayed layers are defined.
49
+ #
50
+ # @param prev_type [String] the current type scope
51
+ # @param new_type [String] the new type scope
52
+ # @param key [String] the content hash
53
+ # @return [String] the content hash
54
+ # @raise [Dis::Errors::NoLayersError] if no writeable immediate
55
+ # layers exist
56
+ # @raise [Dis::Errors::NotFoundError] if the file is not found
43
57
  #
58
+ # @example
44
59
  # Dis::Storage.change_type("old_things", "new_things", key)
45
60
  def change_type(prev_type, new_type, key)
46
61
  require_writeable_layers!
@@ -49,29 +64,37 @@ module Dis
49
64
  layers.immediate.writeable.each do |layer|
50
65
  layer.delete(prev_type, key)
51
66
  end
52
- if layers.delayed.writeable.any?
53
- Dis::Jobs::ChangeType.perform_later(prev_type, new_type, key)
54
- end
67
+ enqueue_delayed_jobs(prev_type, new_type, key)
55
68
  key
56
69
  end
57
70
 
58
- # Stores a file and returns a digest. Kicks off a
59
- # <tt>Dis::Jobs::Store</tt> job if any delayed layers are defined.
71
+ # Stores a file and returns a content hash. Kicks off a
72
+ # {Dis::Jobs::Store} job if any delayed layers are defined.
60
73
  #
61
- # hash = Dis::Storage.store("things", File.open('foo.bin'))
74
+ # @param type [String] the type scope (e.g. table name)
75
+ # @param file [File, IO, String, Fog::Model] the content to store
76
+ # @return [String] the SHA1 content hash
77
+ # @raise [Dis::Errors::NoLayersError] if no writeable immediate
78
+ # layers exist
79
+ #
80
+ # @example
81
+ # hash = Dis::Storage.store("things", File.open("foo.bin"))
62
82
  # # => "8843d7f92416211de9ebb963ff4ce28125932878"
63
83
  def store(type, file)
64
84
  require_writeable_layers!
65
85
  hash = store_immediately!(type, file)
66
- if layers.delayed.writeable.any?
67
- Dis::Jobs::Store.perform_later(type, hash)
68
- end
86
+ Dis::Jobs::Store.perform_later(type, hash) if layers.delayed.writeable.any?
87
+ Dis::Jobs::Evict.perform_later if layers.cache?
69
88
  hash
70
89
  end
71
90
 
72
91
  # Transfers files from immediate layers to all delayed layers.
92
+ # Called internally by {Dis::Jobs::Store}.
73
93
  #
74
- # Dis::Storage.delayed_store("things", hash)
94
+ # @param type [String] the type scope
95
+ # @param hash [String] the content hash
96
+ # @return [void]
97
+ # @raise [Dis::Errors::NotFoundError] if the file is not found
75
98
  def delayed_store(type, hash)
76
99
  file = get(type, hash)
77
100
  layers.delayed.writeable.each do |layer|
@@ -81,75 +104,171 @@ module Dis
81
104
 
82
105
  # Returns true if the file exists in any layer.
83
106
  #
107
+ # @param type [String] the type scope
108
+ # @param key [String] the content hash
109
+ # @return [Boolean]
110
+ # @raise [Dis::Errors::NoLayersError] if no layers are configured
111
+ #
112
+ # @example
84
113
  # Dis::Storage.exists?("things", key) # => true
85
114
  def exists?(type, key)
86
115
  require_layers!
87
116
  layers.each do |layer|
88
117
  return true if layer.exists?(type, key)
118
+ rescue StandardError => e
119
+ report_layer_error(e, layer:, type:, key:)
89
120
  end
90
121
  false
91
122
  end
92
123
 
93
- # Retrieves a file from the store.
124
+ # Retrieves a file from the store. If the first layer misses,
125
+ # the file is fetched from the next available layer and
126
+ # backfilled to all immediate layers.
94
127
  #
95
- # stuff = Dis::Storage.get("things", hash)
128
+ # @param type [String] the type scope
129
+ # @param key [String] the content hash
130
+ # @return [Fog::Model] the stored file
131
+ # @raise [Dis::Errors::NoLayersError] if no layers are configured
132
+ # @raise [Dis::Errors::NotFoundError] if the file is not found
133
+ # in any layer
96
134
  #
97
- # If any misses are detected, it will try to fetch the file from the
98
- # first available layer, then store it in all immediate layer.
99
- #
100
- # Returns an instance of Fog::Model.
135
+ # @example
136
+ # file = Dis::Storage.get("things", hash)
137
+ # file.body # => "file contents..."
101
138
  def get(type, key)
102
139
  require_layers!
103
-
104
140
  fetch_count = 0
105
141
  result = layers.inject(nil) do |res, layer|
106
- res || lambda do
107
- fetch_count += 1
108
- layer.get(type, key)
109
- end.call
110
- end || raise(Dis::Errors::NotFoundError)
142
+ next res if res
111
143
 
112
- store_immediately!(type, result) if fetch_count > 1
144
+ fetch_count += 1
145
+ fetch_from_layer(layer, type, key)
146
+ end || raise(Dis::Errors::NotFoundError)
147
+ backfill!(type, result) if fetch_count > 1
113
148
  result
114
149
  end
115
150
 
116
151
  # Returns the absolute file path from the first layer that has a
117
152
  # local copy, or nil if no layer stores files locally.
118
153
  #
119
- # Dis::Storage.file_path("things", key)
154
+ # @param type [String] the type scope
155
+ # @param key [String] the content hash
156
+ # @return [String, nil] the absolute file path, or nil
157
+ # @raise [Dis::Errors::NoLayersError] if no layers are configured
120
158
  def file_path(type, key)
121
159
  require_layers!
122
160
  layers.each do |layer|
123
161
  path = layer.file_path(type, key)
124
162
  return path if path
163
+ rescue StandardError => e
164
+ report_layer_error(e, layer:, type:, key:)
125
165
  end
126
166
  nil
127
167
  end
128
168
 
169
+ # Streams the contents of a file into the given file, fetching
170
+ # from the first layer that has it. Backfills faster layers if
171
+ # the content had to be fetched from further down.
172
+ #
173
+ # @param type [String] the type scope
174
+ # @param key [String] the content hash
175
+ # @param file [File] the destination, must respond to +path+
176
+ # @return [File] the file that was written to, positioned at
177
+ # the start
178
+ # @raise [Dis::Errors::NoLayersError] if no layers are configured
179
+ # @raise [Dis::Errors::NotFoundError] if the file is not found
180
+ def get_file(type, key, file)
181
+ require_layers!
182
+ fetch_count = 0
183
+ found = layers.detect do |layer|
184
+ fetch_count += 1
185
+ stream_from_layer(layer, type, key, file)
186
+ end
187
+ raise Dis::Errors::NotFoundError unless found
188
+
189
+ finalize_fetched_file(type, file, fetch_count)
190
+ end
191
+
129
192
  # Deletes a file from all layers. Kicks off a
130
- # <tt>Dis::Jobs::Delete</tt> job if any delayed layers are defined.
131
- # Returns true if the file existed in any immediate layers,
132
- # or false if not.
133
- #
134
- # Dis::Storage.delete("things", key)
135
- # # => true
136
- # Dis::Storage.delete("things", key)
137
- # # => false
193
+ # {Dis::Jobs::Delete} job if any delayed layers are defined.
194
+ #
195
+ # @param type [String] the type scope
196
+ # @param key [String] the content hash
197
+ # @return [Boolean] true if the file existed in any immediate
198
+ # layer
199
+ # @raise [Dis::Errors::NoLayersError] if no writeable immediate
200
+ # layers exist
201
+ #
202
+ # @example
203
+ # Dis::Storage.delete("things", key) # => true
204
+ # Dis::Storage.delete("things", key) # => false
138
205
  def delete(type, key)
139
206
  require_writeable_layers!
140
207
  deleted = false
141
208
  layers.immediate.writeable.each do |layer|
142
209
  deleted = true if layer.delete(type, key)
143
210
  end
144
- if layers.delayed.writeable.any?
145
- Dis::Jobs::Delete.perform_later(type, key)
146
- end
211
+ Dis::Jobs::Delete.perform_later(type, key) if layers.delayed.writeable.any?
147
212
  deleted
148
213
  end
149
214
 
215
+ # Evicts cached files from all cache layers that exceed
216
+ # their size limit. Only evicts files that have been
217
+ # replicated to a non-cache writeable layer.
218
+ #
219
+ # @return [void]
220
+ def evict_caches
221
+ layers.cache.each { |layer| evict_cache(layer) }
222
+ end
223
+
224
+ # Returns content hashes from the model's table that exist in
225
+ # no non-cache layer.
226
+ #
227
+ # @param model [Class] an ActiveRecord model that includes
228
+ # {Dis::Model}
229
+ # @yield [batch_size] called after each batch is checked
230
+ # @yieldparam batch_size [Integer] the number of keys in the
231
+ # batch
232
+ # @return [Array<String>] content hashes with no backing file
233
+ #
234
+ # @example
235
+ # Dis::Storage.missing_keys(Image)
236
+ def missing_keys(model)
237
+ attr = model.dis_attributes[:content_hash]
238
+ missing = []
239
+
240
+ model.where.not(attr => nil).in_batches(of: 200) do |batch|
241
+ keys = batch.pluck(attr)
242
+ missing.concat(uncovered_keys(keys.uniq, model.dis_type))
243
+ yield keys.size if block_given?
244
+ end
245
+ missing.uniq
246
+ end
247
+
248
+ # Returns a hash of layer => orphaned content hashes for files
249
+ # that exist in storage but have no matching database record.
250
+ #
251
+ # @param model [Class] an ActiveRecord model that includes
252
+ # {Dis::Model}
253
+ # @return [Hash{Dis::Layer => Array<String>}] orphaned content
254
+ # hashes per layer
255
+ #
256
+ # @example
257
+ # Dis::Storage.orphaned_keys(Image)
258
+ def orphaned_keys(model)
259
+ layers.non_cache.each_with_object({}) do |layer, result|
260
+ orphans = layer_orphans(layer, model.dis_type, model,
261
+ model.dis_attributes[:content_hash])
262
+ result[layer] = orphans if orphans.any?
263
+ end
264
+ end
265
+
150
266
  # Deletes content from all delayed layers.
267
+ # Called internally by {Dis::Jobs::Delete}.
151
268
  #
152
- # Dis::Storage.delayed_delete("things", hash)
269
+ # @param type [String] the type scope
270
+ # @param key [String] the content hash
271
+ # @return [void]
153
272
  def delayed_delete(type, key)
154
273
  layers.delayed.writeable.each do |layer|
155
274
  layer.delete(type, key)
@@ -158,6 +277,85 @@ module Dis
158
277
 
159
278
  private
160
279
 
280
+ def enqueue_delayed_jobs(prev_type, new_type, key)
281
+ if layers.delayed.writeable.any?
282
+ Dis::Jobs::ChangeType.perform_later(
283
+ prev_type, new_type, key
284
+ )
285
+ end
286
+ Dis::Jobs::Evict.perform_later if layers.cache?
287
+ end
288
+
289
+ def uncovered_keys(keys, type)
290
+ remaining = keys.dup
291
+ layers.non_cache.each do |layer|
292
+ break if remaining.empty?
293
+
294
+ remaining -= layer.existing(type, remaining)
295
+ end
296
+ remaining
297
+ end
298
+
299
+ def layer_orphans(layer, type, model, attr)
300
+ stored = layer.stored_keys(type)
301
+ return [] if stored.empty?
302
+
303
+ referenced = model.where(attr => stored).pluck(attr)
304
+ stored - referenced
305
+ end
306
+
307
+ def evict_cache(layer)
308
+ return if layer.size <= layer.max_size
309
+
310
+ current_size = layer.size
311
+ layer.cached_files.each do |entry|
312
+ break if current_size <= layer.max_size
313
+
314
+ next unless replicated?(entry[:type], entry[:key])
315
+
316
+ layer.delete(entry[:type], entry[:key])
317
+ current_size -= entry[:size]
318
+ end
319
+ end
320
+
321
+ def replicated?(type, key)
322
+ layers.non_cache.writeable.any? do |l|
323
+ l.exists?(type, key)
324
+ rescue StandardError => e
325
+ report_layer_error(e, layer: l, type:, key:)
326
+ false
327
+ end
328
+ end
329
+
330
+ def finalize_fetched_file(type, file, fetch_count)
331
+ file.flush
332
+ backfill!(type, file) if fetch_count > 1
333
+ file.rewind
334
+ file
335
+ end
336
+
337
+ def stream_from_layer(layer, type, key, file)
338
+ file.truncate(0)
339
+ file.rewind
340
+ layer.stream(type, key, file)
341
+ rescue StandardError => e
342
+ report_layer_error(e, layer:, type:, key:)
343
+ false
344
+ end
345
+
346
+ def fetch_from_layer(layer, type, key)
347
+ layer.get(type, key)
348
+ rescue StandardError => e
349
+ report_layer_error(e, layer:, type:, key:)
350
+ nil
351
+ end
352
+
353
+ def backfill!(type, file)
354
+ store_immediately!(type, file)
355
+ rescue StandardError => e
356
+ report_layer_error(e, type:)
357
+ end
358
+
161
359
  def store_immediately!(type, file)
162
360
  file_digest(file) do |hash|
163
361
  layers.immediate.writeable.each do |layer|
@@ -174,6 +372,14 @@ module Dis
174
372
  raise Dis::Errors::NoLayersError unless layers.immediate.writeable.any?
175
373
  end
176
374
 
375
+ def report_layer_error(err, layer: nil, type: nil, key: nil)
376
+ Rails.error.report(
377
+ err, handled: true,
378
+ severity: :warning,
379
+ context: { layer: layer&.name, type:, key: }
380
+ )
381
+ end
382
+
177
383
  def digest
178
384
  Digest::SHA1
179
385
  end
@@ -4,9 +4,17 @@ module Dis
4
4
  module Validations
5
5
  # = Dis Data Presence Validation
6
6
  #
7
+ # Validates that data has been assigned to a {Dis::Model} record.
8
+ # Empty strings are treated as missing data.
9
+ #
10
+ # @see Dis::Model::ClassMethods#validates_data_presence
7
11
  class DataPresence < ActiveModel::Validator
8
12
  # Validates that a record has data, either freshly assigned or
9
- # persisted in the storage. Adds a `:blank` error on `:data`if not.
13
+ # persisted in the storage. Adds a +:blank+ error on +:data+
14
+ # if not.
15
+ #
16
+ # @param record [ActiveRecord::Base]
17
+ # @return [void]
10
18
  def validate(record)
11
19
  return if record.data? && record.content_hash != self.class.empty_hash
12
20
 
data/lib/dis/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dis
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  end
data/lib/dis.rb CHANGED
@@ -5,7 +5,7 @@ require "digest/sha1"
5
5
  require "fog/core"
6
6
  require "fog/local"
7
7
  require "active_job"
8
- require "pmap"
8
+ require "concurrent"
9
9
  require "dis/engine"
10
10
  require "dis/errors"
11
11
  require "dis/jobs"
@@ -16,5 +16,18 @@ require "dis/model"
16
16
  require "dis/storage"
17
17
  require "dis/validations"
18
18
 
19
+ # Dis is a content-addressable store for file uploads in Rails.
20
+ #
21
+ # Files are stored as binary blobs keyed by the SHA1 digest of their
22
+ # contents, enabling automatic deduplication. Storage is organized in
23
+ # layers (see {Dis::Layer}) that can target local disk or any cloud
24
+ # provider supported by Fog.
25
+ #
26
+ # Include {Dis::Model} in an ActiveRecord model to get started, and
27
+ # configure layers via {Dis::Storage.layers}.
28
+ #
29
+ # @see Dis::Model
30
+ # @see Dis::Storage
31
+ # @see Dis::Layer
19
32
  module Dis
20
33
  end
@@ -9,6 +9,17 @@ Dis::Storage.layers << Dis::Layer.new(
9
9
  path: Rails.env
10
10
  )
11
11
 
12
+ # You can also use a cache layer with bounded storage and LRU eviction:
13
+
14
+ # Dis::Storage.layers << Dis::Layer.new(
15
+ # Fog::Storage.new(
16
+ # provider: "Local",
17
+ # local_root: Rails.root.join("tmp/dis")
18
+ # ),
19
+ # path: Rails.env,
20
+ # cache: 1.gigabyte
21
+ # )
22
+
12
23
  # You can also add cloud storage:
13
24
 
14
25
  # require 'fog/aws/storage'
data/lib/tasks/dis.rake CHANGED
@@ -1,58 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruby-progressbar"
4
+
3
5
  namespace :dis do
4
- desc "Check stuff"
5
- task consistency_check: :environment do
6
+ desc "List records with no backing file in any storage layer"
7
+ task missing: :environment do
6
8
  unless ENV["MODELS"]
7
- puts "Usage: #{$PROGRAM_NAME} dis:consistency_check " \
8
- "MODELS=Avatar,Document"
9
+ puts "Usage: #{$PROGRAM_NAME} dis:missing MODELS=Avatar,Document"
9
10
  exit
10
11
  end
11
12
 
12
13
  models = ENV["MODELS"].split(",").map(&:strip).map(&:constantize)
13
14
 
14
- jobs = Set.new
15
-
16
15
  models.each do |model|
17
- puts "-- #{model.name} --"
18
-
19
- content_hash_attr = model.dis_attributes[:content_hash]
20
- objects = model.pluck(content_hash_attr).uniq
21
- global_missing = objects.dup
22
-
23
- puts "Unique objects: #{objects.length}"
16
+ bar = ProgressBar.create(
17
+ title: model.name,
18
+ total: model.where.not(
19
+ model.dis_attributes[:content_hash] => nil
20
+ ).count,
21
+ format: "%t: |%B| %c/%C records"
22
+ )
24
23
 
25
- Dis::Storage.layers.each do |layer|
26
- print "Checking #{layer.name}... "
27
-
28
- existing = layer.existing(model.dis_type, objects)
29
- missing = objects - existing
30
- global_missing -= existing
31
- puts "#{existing.length} existing, #{missing.length} missing" +
32
- (layer.readonly? ? " (read-only)" : "")
33
-
34
- next unless layer.delayed? && !layer.readonly?
35
-
36
- jobs += (missing - global_missing).pmap do |hash|
37
- [model.dis_type, hash]
38
- end.compact
24
+ missing = ActiveRecord::Base.logger.silence do
25
+ Dis::Storage.missing_keys(model) do |count|
26
+ bar.progress += count
27
+ end
39
28
  end
29
+ bar.finish
40
30
 
41
- if global_missing.any?
42
- puts "\n#{global_missing.length} objects are missing from all layers:"
43
- pp global_missing
31
+ if missing.any?
32
+ puts "#{missing.length} missing:"
33
+ missing.each { |key| puts " #{key}" }
34
+ else
35
+ puts "0 missing"
44
36
  end
37
+ end
38
+ end
45
39
 
46
- puts
40
+ desc "List stored files with no matching database record"
41
+ task orphaned: :environment do
42
+ unless ENV["MODELS"]
43
+ puts "Usage: #{$PROGRAM_NAME} dis:orphaned MODELS=Avatar,Document"
44
+ exit
47
45
  end
48
46
 
49
- if jobs.any?
50
- print "#{jobs.length} objects can be transferred to delayed layers, " \
51
- "queue now? (y/n) "
52
- response = $stdin.gets.chomp
53
- if /^y/i.match?(response)
54
- puts "Queueing jobs..."
55
- jobs.each { |type, hash| Dis::Jobs::Store.perform_later(type, hash) }
47
+ models = ENV["MODELS"].split(",").map(&:strip).map(&:constantize)
48
+
49
+ models.each do |model|
50
+ orphans = ActiveRecord::Base.logger.silence do
51
+ Dis::Storage.orphaned_keys(model)
52
+ end
53
+ if orphans.any?
54
+ orphans.each do |layer, keys|
55
+ puts "#{model.name} (#{layer.name}): " \
56
+ "#{keys.length} orphaned"
57
+ keys.each { |key| puts " #{key}" }
58
+ end
59
+ else
60
+ puts "#{model.name}: 0 orphaned"
56
61
  end
57
62
  end
58
63
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inge Jørgensen
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: fog-core
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -58,33 +72,33 @@ dependencies:
58
72
  - !ruby/object:Gem::Version
59
73
  version: '0'
60
74
  - !ruby/object:Gem::Dependency
61
- name: pmap
75
+ name: rails
62
76
  requirement: !ruby/object:Gem::Requirement
63
77
  requirements:
64
- - - "~>"
78
+ - - ">="
65
79
  - !ruby/object:Gem::Version
66
- version: 1.1.0
80
+ version: '7.1'
67
81
  type: :runtime
68
82
  prerelease: false
69
83
  version_requirements: !ruby/object:Gem::Requirement
70
84
  requirements:
71
- - - "~>"
85
+ - - ">="
72
86
  - !ruby/object:Gem::Version
73
- version: 1.1.0
87
+ version: '7.1'
74
88
  - !ruby/object:Gem::Dependency
75
- name: rails
89
+ name: ruby-progressbar
76
90
  requirement: !ruby/object:Gem::Requirement
77
91
  requirements:
78
- - - ">"
92
+ - - "~>"
79
93
  - !ruby/object:Gem::Version
80
- version: '5.0'
94
+ version: '1.11'
81
95
  type: :runtime
82
96
  prerelease: false
83
97
  version_requirements: !ruby/object:Gem::Requirement
84
98
  requirements:
85
- - - ">"
99
+ - - "~>"
86
100
  - !ruby/object:Gem::Version
87
- version: '5.0'
101
+ version: '1.11'
88
102
  description: Dis is a Rails plugin that stores your file uploads and other binary
89
103
  blobs.
90
104
  email:
@@ -93,6 +107,7 @@ executables: []
93
107
  extensions: []
94
108
  extra_rdoc_files: []
95
109
  files:
110
+ - LICENSE
96
111
  - README.md
97
112
  - lib/dis.rb
98
113
  - lib/dis/engine.rb
@@ -100,6 +115,7 @@ files:
100
115
  - lib/dis/jobs.rb
101
116
  - lib/dis/jobs/change_type.rb
102
117
  - lib/dis/jobs/delete.rb
118
+ - lib/dis/jobs/evict.rb
103
119
  - lib/dis/jobs/store.rb
104
120
  - lib/dis/layer.rb
105
121
  - lib/dis/layers.rb
@@ -134,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
150
  - !ruby/object:Gem::Version
135
151
  version: '0'
136
152
  requirements: []
137
- rubygems_version: 4.0.3
153
+ rubygems_version: 4.0.10
138
154
  specification_version: 4
139
155
  summary: A file store for your Rails app
140
156
  test_files: []