dolly 3.1.5 → 3.2.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.
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dolly/curl/document_helper'
4
+ require 'dolly/exceptions'
5
+ require 'dolly/curl/response_formatter'
6
+ require 'dolly/curl/stale_connection'
7
+
8
+ module Dolly
9
+ module Curl
10
+ # Reconciles ambiguous CouchDB writes after stale keep-alive failures.
11
+ # Depends on a transport that responds to +perform+ and +discard_handle+.
12
+ class WriteReconciler
13
+ include ResponseFormatter
14
+
15
+ def initialize(transport:, reader:, db_name:)
16
+ @transport = transport
17
+ @reader = reader
18
+ @documents = DocumentHelper.new(db_name: db_name)
19
+ end
20
+
21
+ def reconcile(method, uri, data, error, &block)
22
+ case method.to_sym
23
+ when :put then reconcile_put(uri, data, error, &block)
24
+ when :delete then reconcile_delete(uri, data, error, &block)
25
+ when :post then reconcile_post(uri, data, error, &block)
26
+ else raise_ambiguous(method, uri, error)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :transport, :reader, :documents
33
+
34
+ def reconcile_post(uri, data, error, &block)
35
+ return reconcile_bulk_docs(uri, data, error, &block) if bulk_docs?(uri)
36
+
37
+ raise_ambiguous(:post, uri, error)
38
+ end
39
+
40
+ def reconcile_put(uri, data, error, &block)
41
+ doc_id, intended = put_target(uri, data, error)
42
+ resolve_put(uri, data, doc_id, intended, error, &block)
43
+ rescue *StaleConnection::ERRORS => retry_error
44
+ transport.discard_handle
45
+ raise_ambiguous(:put, uri, retry_error)
46
+ end
47
+
48
+ def put_target(uri, data, error)
49
+ doc_id = documents.id_from_uri(uri)
50
+ raise_ambiguous(:put, uri, error) unless doc_id
51
+
52
+ intended = documents.normalize(data)
53
+ raise_ambiguous(:put, uri, error) unless intended
54
+
55
+ [doc_id, intended]
56
+ end
57
+
58
+ def resolve_put(uri, data, doc_id, intended, error, &block)
59
+ stored = reader.fetch_document(doc_id)
60
+ return put_success(stored) if put_already_applied?(stored, intended)
61
+ return transport.perform(:put, uri, data, &block) if put_retryable?(stored, intended)
62
+
63
+ raise_ambiguous(:put, uri, error)
64
+ end
65
+
66
+ def put_already_applied?(stored, intended)
67
+ stored && documents.matches?(stored, intended)
68
+ end
69
+
70
+ def put_retryable?(stored, intended)
71
+ return documents.revision(intended).nil? if stored.nil?
72
+
73
+ revision = documents.revision(intended)
74
+ revision && documents.value(stored, :_rev) == revision
75
+ end
76
+
77
+ def put_success(stored)
78
+ {
79
+ ok: true,
80
+ id: documents.value(stored, :_id),
81
+ rev: documents.value(stored, :_rev)
82
+ }
83
+ end
84
+
85
+ def reconcile_delete(uri, data, error, &block)
86
+ doc_id = documents.id_from_uri(uri)
87
+ raise_ambiguous(:delete, uri, error) unless doc_id
88
+
89
+ expected_rev = documents.rev_from_uri(uri)
90
+ raise_ambiguous(:delete, uri, error) unless expected_rev
91
+
92
+ resolve_delete(uri, data, doc_id, expected_rev, error, &block)
93
+ rescue *StaleConnection::ERRORS => retry_error
94
+ transport.discard_handle
95
+ raise_ambiguous(:delete, uri, retry_error)
96
+ end
97
+
98
+ def resolve_delete(uri, data, doc_id, expected_rev, error, &block)
99
+ stored = reader.fetch_document(doc_id)
100
+ return { ok: true, id: doc_id } if stored.nil?
101
+ return { ok: true, id: doc_id } if documents.deleted?(stored)
102
+ return transport.perform(:delete, uri, data, &block) if documents.value(stored, :_rev) == expected_rev
103
+
104
+ raise_ambiguous(:delete, uri, error)
105
+ end
106
+
107
+ def reconcile_bulk_docs(uri, data, error, &block)
108
+ docs = documents.docs_from_bulk_payload(data)
109
+ raise_ambiguous(:post, uri, error) if docs.empty?
110
+
111
+ ids = docs.map { |doc| documents.id(doc) }.compact
112
+ raise_ambiguous(:post, uri, error) if ids.empty? || ids.size != docs.size
113
+
114
+ classify_and_retry_bulk(uri, docs, ids, error, &block)
115
+ rescue *StaleConnection::ERRORS => retry_error
116
+ transport.discard_handle
117
+ raise_ambiguous(:post, uri, retry_error)
118
+ end
119
+
120
+ def classify_and_retry_bulk(uri, docs, ids, error, &block)
121
+ rows_by_id = reader.fetch_documents_by_ids(ids)
122
+ results = []
123
+ pending = []
124
+
125
+ docs.each do |doc|
126
+ row = rows_by_id[documents.id(doc)]
127
+ stored = row && row[:doc]
128
+
129
+ if bulk_committed?(doc, stored, row)
130
+ results << bulk_success(doc, stored)
131
+ elsif bulk_unapplied?(doc, stored)
132
+ pending << doc
133
+ else
134
+ raise_ambiguous(:post, uri, error)
135
+ end
136
+ end
137
+
138
+ results.concat(retry_pending_bulk(uri, pending, &block)) if pending.any?
139
+ results
140
+ end
141
+
142
+ def retry_pending_bulk(uri, pending, &block)
143
+ retry_response = transport.perform(:post, uri, { docs: pending }, &block)
144
+ retry_results = response_format(retry_response, :post)
145
+ retry_results = [retry_results] unless retry_results.is_a?(Array)
146
+ retry_results
147
+ end
148
+
149
+ def bulk_committed?(intended, stored, row)
150
+ return true if documents.deleted?(intended) && bulk_deleted?(stored, row)
151
+ return false if stored.nil?
152
+
153
+ documents.matches?(stored, intended)
154
+ end
155
+
156
+ def bulk_deleted?(stored, row)
157
+ stored.nil? || documents.deleted?(stored) || row&.dig(:value, :deleted)
158
+ end
159
+
160
+ def bulk_unapplied?(intended, stored)
161
+ return true if stored.nil? && !documents.deleted?(intended)
162
+ return true if delete_still_present?(intended, stored)
163
+ return true if update_still_at_source_rev?(intended, stored)
164
+
165
+ false
166
+ end
167
+
168
+ def delete_still_present?(intended, stored)
169
+ documents.deleted?(intended) &&
170
+ stored &&
171
+ !documents.deleted?(stored) &&
172
+ documents.value(stored, :_rev) == documents.revision(intended)
173
+ end
174
+
175
+ def update_still_at_source_rev?(intended, stored)
176
+ !documents.deleted?(intended) &&
177
+ stored &&
178
+ documents.revision(intended) &&
179
+ documents.value(stored, :_rev) == documents.revision(intended)
180
+ end
181
+
182
+ def bulk_success(intended, stored)
183
+ id = documents.id(intended)
184
+ return deleted_bulk_success(id, stored) if documents.deleted?(intended)
185
+
186
+ { ok: true, id: id, rev: documents.value(stored, :_rev) }
187
+ end
188
+
189
+ def deleted_bulk_success(id, stored)
190
+ result = { ok: true, id: id }
191
+ rev = stored && documents.value(stored, :_rev)
192
+ result[:rev] = rev if rev
193
+ result
194
+ end
195
+
196
+ def bulk_docs?(uri)
197
+ uri.path.end_with?('/_bulk_docs')
198
+ end
199
+
200
+ def raise_ambiguous(method, uri, error)
201
+ raise Dolly::AmbiguousWriteError.new(method: method, uri: uri, cause: error)
202
+ end
203
+ end
204
+ end
205
+ end
data/lib/dolly/curl.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dolly/curl/stale_connection'
4
+ require 'dolly/curl/response_formatter'
5
+ require 'dolly/curl/document_helper'
6
+ require 'dolly/curl/reader'
7
+ require 'dolly/curl/write_reconciler'
8
+ require 'dolly/curl/connection'
9
+
10
+ module Dolly
11
+ # Namespace for Curb-based HTTP transport collaborators.
12
+ #
13
+ # Public CouchDB client API remains Dolly::Connection. Classes under
14
+ # Dolly::Curl are internal transport/retry helpers.
15
+ module Curl
16
+ end
17
+ end
@@ -58,7 +58,7 @@ module Dolly
58
58
  end
59
59
 
60
60
  def doc_for_framework
61
- return @doc if @doc
61
+ return @doc if defined?(@doc) && @doc
62
62
 
63
63
  return {} unless rails?
64
64
 
@@ -47,6 +47,22 @@ module Dolly
47
47
  end
48
48
  end
49
49
 
50
+ class AmbiguousWriteError < RuntimeError
51
+ attr_reader :method, :uri, :cause
52
+
53
+ def initialize(method:, uri:, cause:)
54
+ @method = method
55
+ @uri = uri
56
+ @cause = cause
57
+ super()
58
+ end
59
+
60
+ def to_s
61
+ "Ambiguous #{method.upcase} to #{uri} after #{cause.class}: #{cause.message}. " \
62
+ 'The request may or may not have been applied.'
63
+ end
64
+ end
65
+
50
66
  class PartitionedDataBaseExpectedError < RuntimeError; end
51
67
  class IndexNotFoundError < RuntimeError; end
52
68
  class InvalidConfigFileError < RuntimeError; end
data/lib/dolly/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dolly
2
- VERSION = "3.1.5"
2
+ VERSION = "3.2.0"
3
3
  end
@@ -44,4 +44,71 @@ class BulkDocumentTest < Test::Unit::TestCase
44
44
  assert_equal [], @doc.docs
45
45
  assert_equal [], @doc.payload[:docs]
46
46
  end
47
+
48
+ test 'save reconciles when bulk post is ambiguous but docs are committed' do
49
+ docs = 2.times.map { |i| Doc.new(name: "doc-#{i}").tap { |d| d.id = "doc/#{i}" } }
50
+ docs.each { |d| @doc << d }
51
+
52
+ connection = @doc.connection
53
+ curl = connection.send(:curl_connection)
54
+ curl.stubs(:perform).with do |method, uri, *_|
55
+ method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
56
+ end.raises(Curl::Err::RecvError)
57
+
58
+ curl.reader.stubs(:fetch_documents_by_ids).returns(
59
+ docs.each_with_object({}) do |d, memo|
60
+ memo[d.id] = { id: d.id, doc: d.to_h.merge(_rev: "1-#{d.id}") }
61
+ end
62
+ )
63
+
64
+ @doc.save
65
+
66
+ assert_equal [], @doc.errors
67
+ assert_equal [], @doc.docs
68
+ docs.each { |d| assert_equal "1-#{d.id}", d.rev }
69
+ end
70
+
71
+ test 'save retries only unapplied docs after ambiguous bulk post' do
72
+ committed = Doc.new(name: 'committed').tap { |d| d.id = 'doc/1' }
73
+ pending = Doc.new(name: 'pending').tap { |d| d.id = 'doc/2' }
74
+ @doc << committed
75
+ @doc << pending
76
+
77
+ connection = @doc.connection
78
+ curl = connection.send(:curl_connection)
79
+ bulk_attempts = 0
80
+ retry_body = [{ ok: true, id: 'doc/2', rev: '2-doc/2' }]
81
+ pending_payloads = []
82
+
83
+ curl.stubs(:perform).with do |method, uri, data, *_|
84
+ next false unless method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
85
+
86
+ bulk_attempts += 1
87
+ raise Curl::Err::RecvError if bulk_attempts == 1
88
+
89
+ pending_payloads << data
90
+ true
91
+ end.returns(
92
+ stub(
93
+ status: '200',
94
+ body_str: retry_body.to_json,
95
+ header_str: ''
96
+ )
97
+ )
98
+
99
+ curl.reader.stubs(:fetch_documents_by_ids).returns(
100
+ 'doc/1' => { id: 'doc/1', doc: committed.to_h.merge(_rev: '1-doc/1') },
101
+ 'doc/2' => { id: 'doc/2', error: 'not_found' }
102
+ )
103
+
104
+ @doc.save
105
+
106
+ assert_equal 2, bulk_attempts
107
+ assert_equal 1, pending_payloads.size
108
+ assert_equal ['doc/2'], pending_payloads.first[:docs].map { |d| d[:_id] || d['_id'] }
109
+ assert_equal [], @doc.errors
110
+ assert_equal [], @doc.docs
111
+ assert_equal '1-doc/1', committed.rev
112
+ assert_equal '2-doc/2', pending.rev
113
+ end
47
114
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class ConnectionTest < Test::Unit::TestCase
6
+ setup do
7
+ clear_thread_local_curl!
8
+ @connection = Dolly::Connection.new
9
+ end
10
+
11
+ teardown do
12
+ clear_thread_local_curl!
13
+ end
14
+
15
+ test 'response_format raises ResourceNotFound for 404 responses' do
16
+ response = build_curl_response(status: 404, body: '{"error":"not_found"}')
17
+
18
+ assert_raises(Dolly::ResourceNotFound) do
19
+ @connection.send(:response_format, response, :get)
20
+ end
21
+ end
22
+
23
+ test 'response_format raises ServerError for 5xx responses' do
24
+ response = build_curl_response(status: 500, body: '{"error":"internal"}')
25
+
26
+ error = assert_raises(Dolly::ServerError) do
27
+ @connection.send(:response_format, response, :get)
28
+ end
29
+
30
+ assert_equal 500, error.instance_variable_get(:@msg)
31
+ end
32
+
33
+ test 'response_format raises ServerError for 4xx responses' do
34
+ response = build_curl_response(status: 400, body: '{"error":"bad_request"}')
35
+
36
+ error = assert_raises(Dolly::ServerError) do
37
+ @connection.send(:response_format, response, :get)
38
+ end
39
+
40
+ assert_equal 400, error.instance_variable_get(:@msg)
41
+ end
42
+
43
+ test 'response_format returns headers for head responses' do
44
+ response = build_curl_response(status: 200, body: '', headers: "ETag: \"abc\"\r\n")
45
+
46
+ assert_equal "ETag: \"abc\"\r\n", @connection.send(:response_format, response, :head)
47
+ end
48
+
49
+ test 'response_format returns parsed json for successful responses' do
50
+ response = build_curl_response(status: 200, body: '{"ok":true,"count":2}')
51
+
52
+ assert_equal({ ok: true, count: 2 }, @connection.send(:response_format, response, :get))
53
+ end
54
+
55
+ test 'response_format returns raw body when json parsing fails' do
56
+ response = build_curl_response(status: 200, body: 'plain text')
57
+
58
+ assert_equal 'plain text', @connection.send(:response_format, response, :get)
59
+ end
60
+
61
+ test 'request performs get against couchdb with query params' do
62
+ stub_request(:get, 'http://localhost:5984/test/doc/1?rev=1-abc')
63
+ .to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
64
+
65
+ result = @connection.request(:get, 'doc/1', rev: '1-abc')
66
+
67
+ assert_equal({ ok: true }, result)
68
+ end
69
+
70
+ test 'request path reuses thread local curl between calls' do
71
+ stub_request(:get, %r{http://localhost:5984/test/_uuids})
72
+ .to_return(status: 200, body: '{"uuids":["abc"]}', headers: { 'Content-Type' => 'application/json' })
73
+
74
+ @connection.tools('_uuids')
75
+ first_handle = @connection.send(:curl_connection).thread_local_handle
76
+ @connection.tools('_uuids')
77
+ second_handle = @connection.send(:curl_connection).thread_local_handle
78
+
79
+ assert_same first_handle, second_handle
80
+ end
81
+
82
+ test 'request performs post against couchdb' do
83
+ stub_request(:post, 'http://localhost:5984/test/_bulk_docs')
84
+ .with(body: '{"docs":[]}')
85
+ .to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
86
+
87
+ result = @connection.request(:post, '_bulk_docs', docs: [])
88
+
89
+ assert_equal({ ok: true }, result)
90
+ end
91
+
92
+ test 'request performs put against couchdb' do
93
+ stub_request(:put, 'http://localhost:5984/test/doc%2F1')
94
+ .with(body: '{"foo":"bar"}')
95
+ .to_return(status: 201, body: '{"ok":true,"id":"doc/1","rev":"1-abc"}', headers: { 'Content-Type' => 'application/json' })
96
+
97
+ result = @connection.put('doc/1', foo: 'bar')
98
+
99
+ assert_equal({ ok: true, id: 'doc/1', rev: '1-abc' }, result)
100
+ end
101
+
102
+ test 'request performs delete against couchdb' do
103
+ stub_request(:delete, 'http://localhost:5984/test/doc%2F1?rev=1-abc')
104
+ .to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
105
+
106
+ result = @connection.delete('doc/1', '1-abc')
107
+
108
+ assert_equal({ ok: true }, result)
109
+ end
110
+
111
+ test 'request sends custom headers' do
112
+ stub_request(:put, 'http://localhost:5984/test/doc%2F1/attachment.txt')
113
+ .with(headers: { 'Content-Type' => 'text/plain' })
114
+ .to_return(status: 201, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
115
+
116
+ result = @connection.attach('doc/1', 'attachment.txt', 'payload', 'Content-Type' => 'text/plain')
117
+
118
+ assert_equal({ ok: true }, result)
119
+ end
120
+
121
+ test 'curl transport helpers stay outside the public connection api' do
122
+ assert_raises(NoMethodError) { @connection.curl_connection }
123
+ assert_raises(NoMethodError) { @connection.fetch_document('doc/1') }
124
+ assert_raises(NoMethodError) { @connection.fetch_documents_by_ids(['doc/1']) }
125
+
126
+ curl = @connection.send(:curl_connection)
127
+ assert_equal 'test', curl.db_name
128
+ assert_instance_of Dolly::Curl::Reader, curl.reader
129
+ end
130
+
131
+ private
132
+
133
+ def build_curl_response(status:, body:, headers: '')
134
+ stub(status: status.to_s, body_str: body, header_str: headers)
135
+ end
136
+
137
+ def clear_thread_local_curl!
138
+ store = Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY]
139
+ return unless store
140
+
141
+ store.each_value { |handle| handle.close rescue nil }
142
+ store.clear
143
+ Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY] = nil
144
+ end
145
+ end