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 +4 -4
- data/README.md +176 -79
- data/lib/dis/engine.rb +7 -0
- data/lib/dis/jobs/change_type.rb +1 -1
- data/lib/dis/jobs/delete.rb +1 -1
- data/lib/dis/jobs/evict.rb +1 -1
- data/lib/dis/jobs/store.rb +1 -1
- data/lib/dis/version.rb +1 -1
- data/lib/dis.rb +15 -0
- data/lib/rails/generators/dis/install/templates/initializer.rb +6 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a575bbef81b61b000b4f795344bee54a53d8ecc88db7df9a7f018ccd9feeb89
|
|
4
|
+
data.tar.gz: f61981977a5273b645588d7b37b94d2acd979f5fd376a0cf93dea3395dc53e2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6153bfef79b469d479db61b8306d94a56f142d89f37a3f7a5a79dced3dd4a6f3e68c5f147d15847e299c05bf03e5946e8acecfabbfe1eededd414bc97b7f5b0
|
|
7
|
+
data.tar.gz: e710026b09ead4cfa74d8de64f01652b605402917feee4df1ddbbfa30e4b49a01a770536661d9cf7515d28cd9fa9cace35d4c256c7bf046aa1e85bc665153c3b
|
data/README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
[](https://rubygems.org/gems/dis)
|
|
2
|
-
](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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
`
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
120
|
+
Data is written to storage when the record is saved, and only if the
|
|
121
|
+
record is valid.
|
|
92
122
|
|
|
93
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
143
|
+
# Yields an open file, then closes it
|
|
144
|
+
header = document.open_data { |file| file.read(1024) }
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
# Returns an open file, close it when you're done
|
|
147
|
+
file = document.open_data
|
|
148
|
+
file.close
|
|
134
149
|
```
|
|
135
150
|
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
[MIT License](LICENSE).
|
|
341
|
+
Released under the [MIT License](LICENSE).
|
data/lib/dis/engine.rb
CHANGED
data/lib/dis/jobs/change_type.rb
CHANGED
data/lib/dis/jobs/delete.rb
CHANGED
data/lib/dis/jobs/evict.rb
CHANGED
data/lib/dis/jobs/store.rb
CHANGED
data/lib/dis/version.rb
CHANGED
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.
|
|
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
|