saviour 0.5.10 → 0.5.11

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.
@@ -3,7 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "codeclimate-test-reporter", :group => :test, :require => nil
6
- gem "activesupport", "~> 5.0.0"
7
- gem "activerecord", "~> 5.0.0"
6
+ gem "activesupport", "~> 5.2.0"
7
+ gem "activerecord", "~> 5.2.0"
8
8
 
9
9
  gemspec :path => "../"
@@ -1,6 +1,7 @@
1
1
  require 'saviour/version'
2
2
  require 'saviour/base_uploader'
3
3
  require 'saviour/file'
4
+ require 'saviour/read_only_file'
4
5
  require 'saviour/local_storage'
5
6
  require 'saviour/s3_storage'
6
7
  require 'saviour/config'
@@ -5,13 +5,14 @@ module Saviour
5
5
  attr_reader :persisted_path
6
6
  attr_reader :source
7
7
 
8
- def initialize(uploader_klass, model, attached_as)
8
+ def initialize(uploader_klass, model, attached_as, persisted_path = nil)
9
9
  @uploader_klass, @model, @attached_as = uploader_klass, model, attached_as
10
10
  @source_was = @source = nil
11
- end
11
+ @persisted_path = persisted_path
12
12
 
13
- def set_path!(path)
14
- @persisted_path = path
13
+ if persisted_path
14
+ @model.instance_variable_set("@__uploader_#{@attached_as}_was", ReadOnlyFile.new(persisted_path))
15
+ end
15
16
  end
16
17
 
17
18
  def exists?
@@ -24,7 +25,6 @@ module Saviour
24
25
  end
25
26
 
26
27
  def delete
27
- persisted? && Config.storage.delete(@persisted_path)
28
28
  @persisted_path = nil
29
29
  @source_was = nil
30
30
  @source = nil
@@ -48,10 +48,7 @@ module Saviour
48
48
 
49
49
  def clone
50
50
  return nil unless persisted?
51
-
52
- new_file = Saviour::File.new(@uploader_klass, @model, @attached_as)
53
- new_file.set_path! @persisted_path
54
- new_file
51
+ Saviour::File.new(@uploader_klass, @model, @attached_as, @persisted_path)
55
52
  end
56
53
 
57
54
  def dup(new_model)
@@ -68,6 +65,7 @@ module Saviour
68
65
 
69
66
  def reload
70
67
  @model.instance_variable_set("@__uploader_#{@attached_as}", nil)
68
+ @model.instance_variable_set("@__uploader_#{@attached_as}_was", nil)
71
69
  end
72
70
 
73
71
  alias_method :url, :public_url
@@ -85,8 +83,8 @@ module Saviour
85
83
  @source_data = nil
86
84
  @source = object
87
85
 
88
- if changed? && @model.respond_to?("#{@attached_as}_will_change!")
89
- @model.send "#{@attached_as}_will_change!"
86
+ if changed? && @model.instance_variable_get("@__uploader_#{@attached_as}_was").nil?
87
+ @model.instance_variable_set("@__uploader_#{@attached_as}_was", clone)
90
88
  end
91
89
 
92
90
  @persisted_path = nil if object
@@ -162,6 +160,7 @@ module Saviour
162
160
  end
163
161
 
164
162
  @persisted_path = path
163
+ @model.instance_variable_set("@__uploader_#{@attached_as}_was", ReadOnlyFile.new(persisted_path))
165
164
  path
166
165
  end
167
166
  end
@@ -177,7 +176,7 @@ module Saviour
177
176
 
178
177
  def source_data
179
178
  @source_data ||= begin
180
- @source.rewind
179
+ @source.rewind if @source.respond_to?(:rewind)
181
180
  @source.read
182
181
  end
183
182
  end
@@ -40,10 +40,8 @@ module Saviour
40
40
  define_method(attach_as) do
41
41
  instance_variable_get("@__uploader_#{attach_as}") || begin
42
42
  uploader_klass = Class.new(Saviour::BaseUploader, &block) if block
43
- new_file = ::Saviour::File.new(uploader_klass, self, attach_as)
44
-
45
43
  layer = persistence_klass.new(self)
46
- new_file.set_path!(layer.read(attach_as))
44
+ new_file = ::Saviour::File.new(uploader_klass, self, attach_as, layer.read(attach_as))
47
45
 
48
46
  instance_variable_set("@__uploader_#{attach_as}", new_file)
49
47
  end
@@ -57,10 +55,34 @@ module Saviour
57
55
  send(attach_as).present?
58
56
  end
59
57
 
58
+ define_method("#{attach_as}_was") do
59
+ instance_variable_get("@__uploader_#{attach_as}_was")
60
+ end
61
+
60
62
  define_method("#{attach_as}_changed?") do
61
63
  send(attach_as).changed?
62
64
  end
63
65
 
66
+ define_method(:changed_attributes) do
67
+ if send("#{attach_as}_changed?")
68
+ super().merge(attach_as => send("#{attach_as}_was"))
69
+ else
70
+ super()
71
+ end
72
+ end
73
+
74
+ define_method(:changes) do
75
+ if send("#{attach_as}_changed?")
76
+ super().merge(attach_as => send("#{attach_as}_change"))
77
+ else
78
+ super()
79
+ end
80
+ end
81
+
82
+ define_method("#{attach_as}_change") do
83
+ [send("#{attach_as}_was"), send(attach_as)]
84
+ end
85
+
64
86
  define_method("remove_#{attach_as}!") do |dependent: nil|
65
87
  if !dependent.nil? && ![:destroy, :ignore].include?(dependent)
66
88
  raise ArgumentError, ":dependent option must be either :destroy or :ignore"
@@ -69,9 +91,12 @@ module Saviour
69
91
  layer = persistence_klass.new(self)
70
92
 
71
93
  attachment_remover = proc do |attach_as|
94
+ layer.write(attach_as, nil)
95
+ deletion_path = send(attach_as).persisted_path
96
+ send(attach_as).delete
97
+
72
98
  work = proc do
73
- send(attach_as).delete
74
- layer.write(attach_as, nil)
99
+ Config.storage.delete(deletion_path) if deletion_path && send(attach_as).persisted_path.nil?
75
100
  end
76
101
 
77
102
  if ActiveRecord::Base.connection.current_transaction.open?
@@ -84,9 +84,25 @@ module Saviour
84
84
 
85
85
  def delete!
86
86
  DbHelpers.run_after_commit do
87
- attached_files.each do |column|
88
- @model.send(column).delete
87
+ pool = Concurrent::FixedThreadPool.new(Saviour::Config.concurrent_workers)
88
+
89
+ futures = attached_files.map do |column|
90
+ Concurrent::Future.execute(executor: pool) {
91
+ path = @model.send(column).persisted_path
92
+ Config.storage.delete(path) if path
93
+ @model.send(column).delete
94
+ }
89
95
  end
96
+
97
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
98
+ futures.each do |future|
99
+ future.value
100
+ raise(future.reason) if future.rejected?
101
+ end
102
+ end
103
+
104
+ pool.shutdown
105
+ pool.wait_for_termination
90
106
  end
91
107
  end
92
108
 
@@ -26,6 +26,7 @@ module Saviour
26
26
  self.class.attached_files.each do |attach_as|
27
27
  duped[attach_as] = nil
28
28
  duped.instance_variable_set("@__uploader_#{attach_as}", send(attach_as).dup(duped))
29
+ duped.instance_variable_set("@__uploader_#{attach_as}_was", nil)
29
30
  end
30
31
 
31
32
  duped
@@ -0,0 +1,35 @@
1
+ module Saviour
2
+ class ReadOnlyFile
3
+ attr_reader :persisted_path
4
+
5
+ def initialize(persisted_path)
6
+ @persisted_path = persisted_path
7
+ end
8
+
9
+ def exists?
10
+ persisted? && Config.storage.exists?(@persisted_path)
11
+ end
12
+
13
+ def read
14
+ return nil unless persisted?
15
+ Config.storage.read(@persisted_path)
16
+ end
17
+
18
+ def public_url
19
+ return nil unless persisted?
20
+ Config.storage.public_url(@persisted_path)
21
+ end
22
+ alias_method :url, :public_url
23
+
24
+ def ==(another_file)
25
+ return false unless another_file.is_a?(Saviour::File) || another_file.is_a?(ReadOnlyFile)
26
+ return false unless another_file.persisted?
27
+
28
+ another_file.persisted_path == persisted_path
29
+ end
30
+
31
+ def persisted?
32
+ true
33
+ end
34
+ end
35
+ end
@@ -1,3 +1,3 @@
1
1
  module Saviour
2
- VERSION = "0.5.10"
2
+ VERSION = "0.5.11"
3
3
  end
@@ -15,8 +15,8 @@ Gem::Specification.new do |spec|
15
15
 
16
16
  spec.required_ruby_version = ">= 2.2.0"
17
17
 
18
- spec.add_dependency "activerecord", ">= 5.0", "< 5.2"
19
- spec.add_dependency "activesupport", ">= 5.0"
18
+ spec.add_dependency "activerecord", ">= 5.1"
19
+ spec.add_dependency "activesupport", ">= 5.1"
20
20
  spec.add_dependency "concurrent-ruby", ">= 1.0.5"
21
21
 
22
22
  spec.add_development_dependency "bundler"
@@ -1,6 +1,6 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe "concurrent processors" do
3
+ describe "concurrency on operations" do
4
4
  before { allow(Saviour::Config).to receive(:storage).and_return(Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com")) }
5
5
 
6
6
  if ENV['TRAVIS']
@@ -131,4 +131,52 @@ describe "concurrent processors" do
131
131
  expect((start_times[0] - start_times[-1]).abs).to be > WAIT_TIME
132
132
  end
133
133
  end
134
+
135
+ context 'on destroy' do
136
+ before do
137
+ allow(Saviour::Config.storage).to receive(:delete) { sleep WAIT_TIME }
138
+ end
139
+
140
+ def measure
141
+ starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
142
+ yield
143
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - starting
144
+ end
145
+
146
+ it 'works concurrently with 4 workers' do
147
+ Saviour::Config.concurrent_workers = 4
148
+
149
+ a = klass.create! file: Saviour::StringSource.new("contents", "file.txt"),
150
+ file_thumb: Saviour::StringSource.new("contents", "file_2.txt"),
151
+ file_thumb_2: Saviour::StringSource.new("contents", "file_3.txt"),
152
+ file_thumb_3: Saviour::StringSource.new("contents", "file_4.txt")
153
+
154
+ time = measure { a.destroy! }
155
+ expect(time).to be_within(THRESHOLD).of(WAIT_TIME)
156
+ end
157
+
158
+ it 'works in serial with 1 worker' do
159
+ Saviour::Config.concurrent_workers = 1
160
+
161
+ a = klass.create! file: Saviour::StringSource.new("contents", "file.txt"),
162
+ file_thumb: Saviour::StringSource.new("contents", "file_2.txt"),
163
+ file_thumb_2: Saviour::StringSource.new("contents", "file_3.txt"),
164
+ file_thumb_3: Saviour::StringSource.new("contents", "file_4.txt")
165
+
166
+ time = measure { a.destroy! }
167
+ expect(time).to be_within(THRESHOLD).of(WAIT_TIME * 4)
168
+ end
169
+
170
+ it 'concurrency can be adjusted' do
171
+ Saviour::Config.concurrent_workers = 2
172
+
173
+ a = klass.create! file: Saviour::StringSource.new("contents", "file.txt"),
174
+ file_thumb: Saviour::StringSource.new("contents", "file_2.txt"),
175
+ file_thumb_2: Saviour::StringSource.new("contents", "file_3.txt"),
176
+ file_thumb_3: Saviour::StringSource.new("contents", "file_4.txt")
177
+
178
+ time = measure { a.destroy! }
179
+ expect(time).to be_within(THRESHOLD).of(WAIT_TIME * 2)
180
+ end
181
+ end
134
182
  end
@@ -123,6 +123,14 @@ describe "CRUD" do
123
123
  end
124
124
  end
125
125
  end
126
+
127
+ it "can be created from another saviour attachment" do
128
+ a = klass.create! file: Saviour::StringSource.new("contents", "file.txt")
129
+ b = klass.create! file: a.file
130
+
131
+ expect(b.file.read).to eq "contents"
132
+ expect(b.file.filename).to eq "file.txt"
133
+ end
126
134
  end
127
135
 
128
136
  describe "deletion" do
@@ -163,6 +171,23 @@ describe "CRUD" do
163
171
  expect(Saviour::Config.storage.read(a[:file])).to eq "foo"
164
172
  end
165
173
 
174
+ it "does not generate an extra query when saving the file with only attached changes" do
175
+ a = klass.create!
176
+
177
+ expect_to_yield_queries(count: 1) do
178
+ a.update_attributes! file: Saviour::StringSource.new("foo", "file.txt")
179
+ end
180
+ end
181
+
182
+ it "does generate an extra query when saving the file with an extra change" do
183
+ a = klass.create!
184
+
185
+ expect_to_yield_queries(count: 2) do
186
+ a.update_attributes! name: "Text",
187
+ file: Saviour::StringSource.new("foo", "file.txt")
188
+ end
189
+ end
190
+
166
191
  context do
167
192
  let(:klass) {
168
193
  a = Class.new(Test) { include Saviour::Model }
@@ -174,9 +199,8 @@ describe "CRUD" do
174
199
  it "saves to db only once with multiple file attachments" do
175
200
  a = klass.create!
176
201
 
177
- # 2 update's, first empty and then 1 with the two attributes
178
202
  expected_query = %Q{UPDATE "tests" SET "file" = '/store/dir/file.txt', "file_thumb" = '/store/dir/file.txt'}
179
- expect_to_yield_queries(count: 2, including: [expected_query]) do
203
+ expect_to_yield_queries(count: 1, including: [expected_query]) do
180
204
  a.update_attributes!(
181
205
  file: Saviour::StringSource.new("foo", "file.txt"),
182
206
  file_thumb: Saviour::StringSource.new("foo", "file.txt")
@@ -3,11 +3,14 @@ require 'spec_helper'
3
3
  describe "dirty model" do
4
4
  before { allow(Saviour::Config).to receive(:storage).and_return(Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com")) }
5
5
 
6
- it "provides changes and previous file" do
6
+ before do
7
7
  uploader = Class.new(Saviour::BaseUploader) { store_dir { "/store/dir" } }
8
- klass = Class.new(Test) { include Saviour::Model }
9
- klass.attach_file :file, uploader
10
- a = klass.create!
8
+ @klass = Class.new(Test) { include Saviour::Model }
9
+ @klass.attach_file :file, uploader
10
+ end
11
+
12
+ it "provides changes and previous file" do
13
+ a = @klass.create!
11
14
 
12
15
  with_test_file("example.xml") do |xml_file|
13
16
  with_test_file("camaloon.jpg") do |jpg_file|
@@ -28,17 +31,76 @@ describe "dirty model" do
28
31
  expect(a.file_was.persisted?).to be_truthy
29
32
 
30
33
  expect(a.changed_attributes).to include("file" => a.file_was)
34
+ expect(a.changed).to eq ["file"]
35
+
36
+ expect(a.changed?).to be_truthy
37
+
38
+ expect(a.changes).to eq({"file" => [a.file_was, a.file]})
31
39
  end
32
40
  end
33
41
  end
34
42
 
43
+ it "provides changes on two changed attributes" do
44
+ a = @klass.create!
45
+
46
+ a.name = "Foo bar"
47
+ a.file = Saviour::StringSource.new("contents", "file.txt")
48
+
49
+ expect(a.changed.sort).to eq ["file", "name"]
50
+ a.save!
51
+
52
+ a.name = "Johny"
53
+ a.file = Saviour::StringSource.new("contents", "file_2.txt")
54
+
55
+ expect(a.changed.sort).to eq ["file", "name"]
56
+ expect(a.changes).to eq({ "file" => [a.file_was, a.file], "name" => ["Foo bar", "Johny"]})
57
+ end
58
+
59
+ it "returns always the first ever known value as was" do
60
+ a = @klass.create! file: Saviour::StringSource.new("contents", "file.txt")
61
+ url_was = a.file.url
62
+
63
+ a.file = Saviour::StringSource.new("contents", "file_45.txt")
64
+ expect(a.file_was.url).to eq url_was
65
+
66
+ a.file = Saviour::StringSource.new("contents", "file_95.txt")
67
+ expect(a.file_was.url).to eq url_was
68
+ end
69
+
35
70
  it "changes are nil when not persisted" do
36
- uploader = Class.new(Saviour::BaseUploader) { store_dir { "/store/dir" } }
37
- klass = Class.new(Test) { include Saviour::Model }
38
- klass.attach_file :file, uploader
71
+ a = @klass.new file: Saviour::StringSource.new("contents", "file.txt")
39
72
 
40
- a = klass.new file: Saviour::StringSource.new("contents", "file.txt")
41
73
  expect(a.file_changed?).to be_truthy
42
74
  expect(a.file_was).to be_nil
43
75
  end
76
+
77
+ it "changes are nil when persisted but no file was assigned" do
78
+ a = @klass.create!
79
+ a.file = Saviour::StringSource.new("contents", "file.txt")
80
+ expect(a.file_was).to be_nil
81
+ end
82
+
83
+ it "was data is kept after reload" do
84
+ a = @klass.create! file: Saviour::StringSource.new("contents", "file.txt"), name: "Cuca"
85
+ expect(a.name_was).to eq "Cuca"
86
+ expect(a.file_was).to eq a.file
87
+
88
+ a.reload
89
+ expect(a.file_was).to eq a.file
90
+ end
91
+
92
+ it "was data is refreshed on persisting a new assignation" do
93
+ a = @klass.create! file: Saviour::StringSource.new("contents", "file.txt")
94
+ a.file = Saviour::StringSource.new("contents", "file_2.txt")
95
+ a.save!
96
+ expect(a.file_was.url).to match(/file_2\.txt/)
97
+ end
98
+
99
+ it "was file is cleared on dup" do
100
+ a = @klass.create! file: Saviour::StringSource.new("contents", "file.txt")
101
+ expect(a.file_was).to eq a.file
102
+
103
+ b = a.dup
104
+ expect(b.file_was).to be_nil
105
+ end
44
106
  end