dis 2.2.2 → 2.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2e88add7c40a81f705af816e6546930dcb13f4921b360073d3aef93d32b080c
4
- data.tar.gz: d1104cfeba051647d8f15c8bbda4e669f3af68f8f2e379f4ceee2ebab938fea4
3
+ metadata.gz: 5a575bbef81b61b000b4f795344bee54a53d8ecc88db7df9a7f018ccd9feeb89
4
+ data.tar.gz: f61981977a5273b645588d7b37b94d2acd979f5fd376a0cf93dea3395dc53e2f
5
5
  SHA512:
6
- metadata.gz: 249e4bb9c4f764baba8e4733b8b4a6d1384a93a6050829ee4eba8e649d7ea0f93454039318f0375bb1ed40d9bdcd3ee3b17e3ac6c7d463cc95339c3b6ece43d3
7
- data.tar.gz: 3b643c306b9dddb9d4f0d12ecaa46918ffbd12e1186802bb38efc02b7897fd56bfa67e4230dbfcb0a60598e1cefc800820960d7bbc798e740417951fc2452f1b
6
+ metadata.gz: b6153bfef79b469d479db61b8306d94a56f142d89f37a3f7a5a79dced3dd4a6f3e68c5f147d15847e299c05bf03e5946e8acecfabbfe1eededd414bc97b7f5b0
7
+ data.tar.gz: e710026b09ead4cfa74d8de64f01652b605402917feee4df1ddbbfa30e4b49a01a770536661d9cf7515d28cd9fa9cace35d4c256c7bf046aa1e85bc665153c3b
data/README.md CHANGED
@@ -1,10 +1,15 @@
1
1
  [![Version](https://img.shields.io/gem/v/dis.svg?style=flat)](https://rubygems.org/gems/dis)
2
- ![Build](https://github.com/elektronaut/dis/workflows/Build/badge.svg)
2
+ [![Build](https://github.com/elektronaut/dis/actions/workflows/build.yml/badge.svg)](https://github.com/elektronaut/dis/actions/workflows/build.yml)
3
3
 
4
4
  # Dis
5
5
 
6
6
  Dis is a content-addressable store for file uploads in your Rails app.
7
7
 
8
+ Files are stored as binary blobs, keyed by the SHA1 digest of their
9
+ contents. Storing the same file twice stores a single blob, the
10
+ second record simply points at the same hash. Deleting a record
11
+ deletes the blob only when no other record refers to it.
12
+
8
13
  Data can be stored either on disk or in the cloud — anywhere
9
14
  [Fog](http://fog.io) can connect to.
10
15
 
@@ -13,11 +18,6 @@ building your own. If you're looking to handle image uploads, check out
13
18
  [DynamicImage](https://github.com/elektronaut/dynamic_image). It's
14
19
  built on top of Dis and handles resizing, cropping and more on demand.
15
20
 
16
- ## Requirements
17
-
18
- - Ruby >= 3.2
19
- - Rails >= 7.1
20
-
21
21
  ## Installation
22
22
 
23
23
  Add the gem to your Gemfile and run `bundle install`:
@@ -41,7 +41,14 @@ additional layers. Cloud storage requires the corresponding
41
41
  gem "fog-aws"
42
42
  ```
43
43
 
44
- ## Usage
44
+ Unless you intend to check your uploads into version control, add the
45
+ storage path to `.gitignore`:
46
+
47
+ ```
48
+ /db/dis
49
+ ```
50
+
51
+ ## Getting started
45
52
 
46
53
  Run the generator to create your model.
47
54
 
@@ -49,15 +56,19 @@ Run the generator to create your model.
49
56
  bin/rails generate dis:model Document
50
57
  ```
51
58
 
52
- This will create a model along with a migration.
59
+ This creates a model along with a migration for the four attributes
60
+ Dis needs: `content_hash`, `content_type`, `content_length` and
61
+ `filename`.
53
62
 
54
- Here's what your model might look like. Dis does not validate any data
55
- by default, but you can use standard Rails validators. A presence
56
- validator for data is also provided.
63
+ Dis does not validate any data by default, but you can use standard
64
+ Rails validators. A presence validator for data is also provided; use
65
+ it rather than `validates :data, presence: true`, which would load the
66
+ data from storage on every save.
57
67
 
58
68
  ```ruby
59
69
  class Document < ActiveRecord::Base
60
70
  include Dis::Model
71
+
61
72
  validates_data_presence
62
73
  validates :content_type, presence: true, format: /\Aapplication\/(x\-)?pdf\z/
63
74
  validates :filename, presence: true, format: /\A[\w_\-\.]+\.pdf\z/i
@@ -65,96 +76,86 @@ class Document < ActiveRecord::Base
65
76
  end
66
77
  ```
67
78
 
68
- To save your document, set the `file` attribute. This extracts
69
- `content_type` and `filename` from the upload automatically.
79
+ Assigning the upload to the `file` attribute stores it, and
80
+ `send_dis_data` streams it back to the client.
70
81
 
71
82
  ```ruby
72
- document_params = params.require(:document).permit(:file)
73
- @document = Document.create(document_params)
83
+ class DocumentsController < ApplicationController
84
+ include Dis::Controller
85
+
86
+ def create
87
+ @document = Document.create(params.expect(document: [:file]))
88
+ redirect_to @document
89
+ end
90
+
91
+ def show
92
+ @document = Document.find(params[:id])
93
+ send_dis_data(@document) if stale?(@document)
94
+ end
95
+ end
74
96
  ```
75
97
 
76
- You can also assign `data` directly, but you'll need to set
77
- `content_type` and `filename` yourself:
98
+ `send_dis_data` works like `send_file`, but reads through an open
99
+ descriptor rather than a path, so the response is unaffected if the
100
+ content is evicted or deleted while it is being written.
101
+
102
+ If the data can't be found in any layer, a `Dis::Errors::NotFoundError`
103
+ is raised.
104
+
105
+ ## Writing data
106
+
107
+ When you assign `file` to an uploaded file, `content_type` and
108
+ `filename` are extracted automatically. You can also assign
109
+ `data` directly, but then you'll need to provide the metadata
110
+ yourself:
78
111
 
79
112
  ```ruby
80
113
  Document.create(data: File.open("document.pdf"),
81
114
  content_type: "application/pdf",
82
115
  filename: "document.pdf")
83
- ```
84
116
 
85
- ...or even a string:
86
-
87
- ```ruby
88
117
  Document.create(data: "foo", content_type: "text/plain", filename: "foo.txt")
89
118
  ```
90
119
 
91
- Reading the file back out:
120
+ Data is written to storage when the record is saved, and only if the
121
+ record is valid.
92
122
 
93
- ```ruby
94
- class DocumentsController < ApplicationController
95
- def show
96
- @document = Document.find(params[:id])
97
- if stale?(@document)
98
- send_data(@document.data,
99
- filename: @document.filename,
100
- type: @document.content_type,
101
- disposition: "attachment")
102
- end
103
- end
104
- end
105
- ```
106
-
107
- ### Accessing the data
108
-
109
- Which accessor you want depends on how long the data needs to stay
110
- valid.
123
+ ## Reading data
111
124
 
112
125
  `data` returns the content as a binary string.
113
126
 
114
127
  ```ruby
128
+ document.data? # => true
115
129
  document.data # => "foobar"
116
130
  ```
117
131
 
118
- `with_data_file` yields a path, for tools that want a file name rather
119
- than the bytes. It is valid for the duration of the block, so resolve
120
- anything lazy before returning.
132
+ This loads the entire file into memory and keeps it there as long as
133
+ the record stays in scope, so be careful with this, particularly
134
+ when iterating over collections. The corresponding `data?` method is
135
+ a bit smarter and doesn't share this gotcha.
121
136
 
122
- ```ruby
123
- document.with_data_file { |path| Vips::Image.new_from_file(path.to_s).avg }
124
- ```
125
-
126
- `open_data` returns an open file, valid until you close it — even if
127
- the content is deleted or evicted from a cache layer meanwhile. Use it
128
- when the reader outlives the current call stack.
137
+ `open_data` returns an open file instead. It remains valid until you
138
+ close it, even if the content is deleted or evicted from a cache
139
+ layer. Use it when the reader outlives the current call stack, which
140
+ is how `send_dis_data` hands data off to the web server.
129
141
 
130
142
  ```ruby
131
- document.open_data { |file| file.read }
143
+ # Yields an open file, then closes it
144
+ header = document.open_data { |file| file.read(1024) }
132
145
 
133
- file = document.open_data # caller closes it
146
+ # Returns an open file, close it when you're done
147
+ file = document.open_data
148
+ file.close
134
149
  ```
135
150
 
136
- ### Sending data from a controller
137
-
138
- Include `Dis::Controller` and use `send_dis_data` to stream a record's
139
- data to the client. It works like `send_file`, but reads through an
140
- open descriptor rather than a path, so the response is unaffected if
141
- the content is evicted or deleted while it is being written.
151
+ `with_data_file` yields a path, for tools that want a file name rather
152
+ than the bytes. It is only valid for the duration of the block.
142
153
 
143
154
  ```ruby
144
- class DocumentsController < ApplicationController
145
- include Dis::Controller
146
-
147
- def show
148
- send_dis_data(Document.find(params[:id]), disposition: "inline")
149
- end
150
- end
155
+ document.with_data_file { |path| Vips::Image.new_from_file(path.to_s).avg }
151
156
  ```
152
157
 
153
- `filename` and `content_type` default to the record's own metadata.
154
- The full set of options is `filename`, `content_type`, `disposition`
155
- and `status`.
156
-
157
- ## Layers
158
+ ## Storage layers
158
159
 
159
160
  The underlying storage consists of one or more layers. Each layer
160
161
  targets either a local path or a cloud provider like Amazon S3 or
@@ -168,8 +169,10 @@ There are three types of layers:
168
169
  - **Cache** layers are bounded, immediate layers with LRU eviction.
169
170
  They act as both a read cache and an upload buffer.
170
171
 
171
- Reads are performed from the first available layer. On a miss, the
172
- file is backfilled from the next layer.
172
+ Reads are attempted in the order the layers were added, and served
173
+ from the first one that has the file. If it had to be fetched from
174
+ further down, it is backfilled to every writeable immediate layer on
175
+ the way out.
173
176
 
174
177
  A typical multi-layer configuration has a local layer first and an
175
178
  Amazon S3 bucket second. This gives you an on-disk cache backed by
@@ -197,9 +200,12 @@ Dis::Storage.layers << Dis::Layer.new(
197
200
  )
198
201
  ```
199
202
 
200
- Layers can be configured as read-only useful for reading from
201
- staging or production while developing locally, or when transitioning
202
- away from a provider.
203
+ At least one writeable, immediate layer is required. Operations raise
204
+ `Dis::Errors::NoLayersError` if none are configured.
205
+
206
+ Layers can also be configured as read-only, which is useful for
207
+ reading from staging or production while developing locally, or when
208
+ transitioning away from a provider.
203
209
 
204
210
  ### Cache layers
205
211
 
@@ -220,7 +226,71 @@ Dis::Storage.layers << Dis::Layer.new(
220
226
  )
221
227
  ```
222
228
 
223
- Cache layers cannot be combined with `delayed` or `readonly`.
229
+ ## Configuration
230
+
231
+ ### Background jobs
232
+
233
+ Delayed layers and cache eviction enqueue ActiveJob jobs. They run on
234
+ the ActiveJob default queue unless you tell Dis otherwise:
235
+
236
+ ```ruby
237
+ Dis.queue = :dis # or config.dis.queue = :dis
238
+ ```
239
+
240
+ ### Storage type
241
+
242
+ Files are stored under a type, which defaults to the model's table
243
+ name and maps to a directory within each layer. Deduplication happens
244
+ within a type, so two different models storing the same file will each
245
+ have their own blob.
246
+
247
+ ```ruby
248
+ class Document < ActiveRecord::Base
249
+ include Dis::Model
250
+ self.dis_type = "files"
251
+ end
252
+ ```
253
+
254
+ Take care not to use the same `dis_type` for two models. They will share
255
+ blobs, and destroying a record in one model will delete data still
256
+ referenced by the other.
257
+
258
+ ### Attribute names
259
+
260
+ If the default column names don't fit your schema, override them with
261
+ `dis_attributes`. Valid keys are `content_hash`, `content_type`,
262
+ `content_length` and `filename`.
263
+
264
+ ```ruby
265
+ class Document < ActiveRecord::Base
266
+ include Dis::Model
267
+ self.dis_attributes = {
268
+ filename: :my_filename,
269
+ content_length: :filesize
270
+ }
271
+ end
272
+ ```
273
+
274
+ ## Maintenance
275
+
276
+ Two rake tasks exist to help you audit the store. Both take a
277
+ comma-separated list of models.
278
+
279
+ ```sh
280
+ bin/rails dis:missing MODELS=Document,Image # records with no file
281
+ bin/rails dis:orphaned MODELS=Document,Image # files with no record
282
+ ```
283
+
284
+ `dis:missing` lists content hashes referenced by records that exist in
285
+ no non-cache layer. `dis:orphaned` lists the reverse, grouped by
286
+ layer: files in storage that no record refers to.
287
+
288
+ The same information is available programmatically:
289
+
290
+ ```ruby
291
+ Dis::Storage.missing_keys(Document) # => ["8843d7f9..."]
292
+ Dis::Storage.orphaned_keys(Document) # => { #<Dis::Layer> => ["8843d7f9..."] }
293
+ ```
224
294
 
225
295
  ## Low-level API
226
296
 
@@ -234,11 +304,38 @@ Dis::Storage.get("documents", hash).body # => "foobar"
234
304
  Dis::Storage.delete("documents", hash) # => true
235
305
  ```
236
306
 
307
+ `get` loads the entire body into memory. To stream instead, write into
308
+ a file you own, or ask for a local path:
309
+
310
+ ```ruby
311
+ File.open("out.txt", "w+b") do |file|
312
+ Dis::Storage.get_file("documents", hash, file)
313
+ end
314
+
315
+ Dis::Storage.file_path("documents", hash) # => "/path/to/db/dis/..." or nil
316
+ ```
317
+
318
+ `file_path` returns a path only if some layer holds the file locally.
319
+
320
+ To move content between types, use `change_type`:
321
+
322
+ ```ruby
323
+ Dis::Storage.change_type("documents", "archived_documents", hash)
324
+ ```
325
+
237
326
  ## Documentation
238
327
 
239
- See the [generated documentation on RubyDoc.info](https://www.rubydoc.info/gems/dis)
328
+ See the [generated documentation on RubyDoc.info](https://www.rubydoc.info/gems/dis),
329
+ and the [changelog](CHANGELOG.md) for release notes.
330
+
331
+ ## Contributing
332
+
333
+ Bug reports and pull requests are welcome on
334
+ [GitHub](https://github.com/elektronaut/dis). See
335
+ [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests and how
336
+ commits are formatted, and note that this project ships with a
337
+ [code of conduct](CODE_OF_CONDUCT.md).
240
338
 
241
339
  ## License
242
340
 
243
- Copyright 2014-2026 Inge Jørgensen. Released under the
244
- [MIT License](LICENSE).
341
+ Released under the [MIT License](LICENSE).
data/lib/dis/engine.rb CHANGED
@@ -2,5 +2,12 @@
2
2
 
3
3
  module Dis
4
4
  class Engine < ::Rails::Engine
5
+ config.dis = ActiveSupport::OrderedOptions.new
6
+
7
+ initializer "dis.config" do |app|
8
+ config.after_initialize do
9
+ Dis.queue = app.config.dis.queue if app.config.dis.key?(:queue)
10
+ end
11
+ end
5
12
  end
6
13
  end
@@ -11,7 +11,7 @@ module Dis
11
11
  # @example
12
12
  # Dis::Jobs::ChangeType.perform_later("old", "new", key)
13
13
  class ChangeType < ActiveJob::Base
14
- queue_as :dis
14
+ queue_as { Dis.queue }
15
15
 
16
16
  retry_on StandardError, attempts: 10, wait: :polynomially_longer
17
17
 
@@ -10,7 +10,7 @@ module Dis
10
10
  # @example
11
11
  # Dis::Jobs::Delete.perform_later("documents", key)
12
12
  class Delete < ActiveJob::Base
13
- queue_as :dis
13
+ queue_as { Dis.queue }
14
14
 
15
15
  retry_on StandardError, attempts: 10, wait: :polynomially_longer
16
16
 
@@ -11,7 +11,7 @@ module Dis
11
11
  # @example
12
12
  # Dis::Jobs::Evict.perform_later
13
13
  class Evict < ActiveJob::Base
14
- queue_as :dis
14
+ queue_as { Dis.queue }
15
15
 
16
16
  retry_on StandardError, attempts: 10, wait: :polynomially_longer
17
17
 
@@ -12,7 +12,7 @@ module Dis
12
12
  # @example
13
13
  # Dis::Jobs::Store.perform_later("documents", key)
14
14
  class Store < ActiveJob::Base
15
- queue_as :dis
15
+ queue_as { Dis.queue }
16
16
 
17
17
  discard_on Dis::Errors::NotFoundError
18
18
 
data/lib/dis/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dis
4
- VERSION = "2.2.2"
4
+ VERSION = "2.3.0"
5
5
  end
data/lib/dis.rb CHANGED
@@ -5,6 +5,7 @@ require "digest/sha1"
5
5
  require "fog/core"
6
6
  require "fog/local"
7
7
  require "active_job"
8
+ require "active_support/core_ext/module/attribute_accessors"
8
9
  require "concurrent"
9
10
  require "dis/controller"
10
11
  require "dis/engine"
@@ -33,4 +34,18 @@ require "dis/validations"
33
34
  # @see Dis::Storage
34
35
  # @see Dis::Layer
35
36
  module Dis
37
+ # The ActiveJob queue used by delayed and cache layers. Defaults to
38
+ # nil, which means jobs run on the ActiveJob default queue. Set it
39
+ # to run them on a dedicated queue instead, either directly or with
40
+ # <tt>config.dis.queue</tt>.
41
+ #
42
+ # Note that your job backend must be configured to process the
43
+ # queue you choose. Sidekiq, Resque and Que only process a single
44
+ # named queue unless told otherwise.
45
+ #
46
+ # @return [String, Symbol, nil] the queue name
47
+ #
48
+ # @example
49
+ # Dis.queue = :dis
50
+ mattr_accessor :queue
36
51
  end
@@ -2,6 +2,12 @@
2
2
 
3
3
  # Be sure to restart your server when you modify this file.
4
4
 
5
+ # Delayed and cache layers enqueue ActiveJob jobs. By default these run
6
+ # on the ActiveJob default queue. Uncomment to use a dedicated queue,
7
+ # and make sure your job backend is configured to process it:
8
+
9
+ # Dis.queue = :dis
10
+
5
11
  # Creates a local storage layer in db/dis:
6
12
 
7
13
  Dis::Storage.layers << Dis::Layer.new(
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: 2.2.2
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inge Jørgensen
@@ -151,7 +151,11 @@ homepage: https://github.com/elektronaut/dis
151
151
  licenses:
152
152
  - MIT
153
153
  metadata:
154
+ bug_tracker_uri: https://github.com/elektronaut/dis/issues
155
+ changelog_uri: https://github.com/elektronaut/dis/blob/main/CHANGELOG.md
156
+ documentation_uri: https://www.rubydoc.info/gems/dis
154
157
  rubygems_mfa_required: 'true'
158
+ source_code_uri: https://github.com/elektronaut/dis
155
159
  rdoc_options: []
156
160
  require_paths:
157
161
  - lib