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,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class Dolly::Curl::ConnectionTest < Test::Unit::TestCase
6
+ setup do
7
+ clear_thread_local_curl!
8
+ @reader = Object.new
9
+ def @reader.fetch_document(*)
10
+ nil
11
+ end
12
+
13
+ def @reader.fetch_documents_by_ids(*)
14
+ {}
15
+ end
16
+
17
+ @curl = Dolly::Curl::Connection.new(db_name: 'test', reader: @reader)
18
+ end
19
+
20
+ teardown do
21
+ clear_thread_local_curl!
22
+ end
23
+
24
+ test 'reuses the same curl handle on a thread' do
25
+ first = @curl.thread_local_handle
26
+ second = @curl.thread_local_handle
27
+
28
+ assert_same first, second
29
+ end
30
+
31
+ test 'uses separate curl handles per thread' do
32
+ handles = Queue.new
33
+
34
+ threads = 2.times.map do
35
+ Thread.new do
36
+ curl = Dolly::Curl::Connection.new(db_name: 'test', reader: stub_everything('reader'))
37
+ handles << curl.thread_local_handle
38
+ ensure
39
+ store = Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY]
40
+ next unless store
41
+
42
+ store.each_value { |handle| handle.close rescue nil }
43
+ Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY] = nil
44
+ end
45
+ end
46
+ threads.each(&:join)
47
+
48
+ collected = []
49
+ collected << handles.pop until handles.empty?
50
+
51
+ assert_equal 2, collected.map(&:object_id).uniq.size
52
+ end
53
+
54
+ test 'discard_handle removes and closes the handle' do
55
+ curl_easy = @curl.thread_local_handle
56
+ curl_easy.expects(:close).once
57
+
58
+ @curl.discard_handle
59
+
60
+ store = Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY]
61
+ assert_nil store[Dolly::Curl::Connection]
62
+ end
63
+
64
+ test 'retries once after a stale connection error on get' do
65
+ response = build_curl_response(status: 200, body: '{"ok":true}')
66
+
67
+ @curl.stubs(:perform)
68
+ .raises(Curl::Err::GotNothingError)
69
+ .then.returns(response)
70
+ @curl.expects(:discard_handle).once
71
+
72
+ result = @curl.request(:get, URI('http://localhost:5984/test/doc'), {})
73
+
74
+ assert_equal response, result
75
+ end
76
+
77
+ test 'raises after stale connection retries are exhausted on get' do
78
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
79
+ @curl.expects(:discard_handle).twice
80
+
81
+ assert_raises(Curl::Err::RecvError) do
82
+ @curl.request(:get, URI('http://localhost:5984/test/doc'), {})
83
+ end
84
+ end
85
+
86
+ test 'retries once after SendError on read-only post' do
87
+ response = build_curl_response(status: 200, body: '{"docs":[]}')
88
+
89
+ @curl.stubs(:perform)
90
+ .raises(Curl::Err::SendError)
91
+ .then.returns(response)
92
+ @curl.expects(:discard_handle).once
93
+
94
+ result = @curl.request(
95
+ :post,
96
+ URI('http://localhost:5984/test/_find'),
97
+ { selector: { type: 'doc' } }
98
+ )
99
+
100
+ assert_equal response, result
101
+ end
102
+
103
+ test 'retries once after PartialFileError on _all_docs post' do
104
+ response = build_curl_response(status: 200, body: '{"rows":[]}')
105
+
106
+ @curl.stubs(:perform)
107
+ .raises(Curl::Err::PartialFileError)
108
+ .then.returns(response)
109
+ @curl.expects(:discard_handle).once
110
+
111
+ result = @curl.request(
112
+ :post,
113
+ URI('http://localhost:5984/test/_all_docs'),
114
+ { keys: ['doc/1'] }
115
+ )
116
+
117
+ assert_equal response, result
118
+ end
119
+
120
+ test 'stale connection retry creates a fresh curl handle' do
121
+ first_handle = @curl.thread_local_handle
122
+ attempts = 0
123
+
124
+ @curl.stubs(:perform).with do |*_args|
125
+ attempts += 1
126
+ raise Curl::Err::GotNothingError if attempts == 1
127
+
128
+ true
129
+ end.returns(build_curl_response(status: 200, body: '{"ok":true}'))
130
+
131
+ @curl.request(:get, URI('http://localhost:5984/test/doc'), {})
132
+ second_handle = @curl.thread_local_handle
133
+
134
+ assert_equal 2, attempts
135
+ refute_same first_handle, second_handle
136
+ end
137
+
138
+ test 'discard_handle is safe when no handle exists' do
139
+ assert_nil @curl.discard_handle
140
+ end
141
+
142
+ test 'discard_handle ignores errors while closing handle' do
143
+ curl_easy = @curl.thread_local_handle
144
+ curl_easy.stubs(:close).raises(StandardError, 'broken handle')
145
+
146
+ assert_nil @curl.discard_handle
147
+ assert_nil Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY][Dolly::Curl::Connection]
148
+ end
149
+
150
+ test 'shared curl connection instances reuse one handle per thread' do
151
+ other = Dolly::Curl::Connection.new(db_name: 'test', reader: stub_everything('reader'))
152
+
153
+ assert_same @curl.thread_local_handle, other.thread_local_handle
154
+ end
155
+
156
+ test 'perform raises for unsupported http methods' do
157
+ error = assert_raises(ArgumentError) do
158
+ @curl.send(:perform, :patch, URI('http://localhost:5984/test/doc'), {})
159
+ end
160
+
161
+ assert_match(/unsupported HTTP method/, error.message)
162
+ end
163
+
164
+ test 'apply_get_query! is a no-op for nil or empty data' do
165
+ curl = Curl::Easy.new
166
+ curl.url = 'http://localhost:5984/test/doc'
167
+
168
+ @curl.send(:apply_get_query!, curl, nil)
169
+ assert_equal 'http://localhost:5984/test/doc', curl.url
170
+
171
+ @curl.send(:apply_get_query!, curl, {})
172
+ assert_equal 'http://localhost:5984/test/doc', curl.url
173
+ end
174
+
175
+ test 'apply_get_query! appends encoded query parameters' do
176
+ curl = Curl::Easy.new
177
+ curl.url = 'http://localhost:5984/test/doc'
178
+
179
+ @curl.send(:apply_get_query!, curl, { 'a' => '1', :b => 'two' })
180
+
181
+ assert_equal 'http://localhost:5984/test/doc?a=1&b=two', curl.url
182
+ end
183
+
184
+ test 'apply_get_query! uses ampersand when url already has query string' do
185
+ curl = Curl::Easy.new
186
+ curl.url = 'http://localhost:5984/test/doc?rev=1'
187
+
188
+ @curl.send(:apply_get_query!, curl, { include_docs: true })
189
+
190
+ assert_equal 'http://localhost:5984/test/doc?rev=1&include_docs=true', curl.url
191
+ end
192
+
193
+ test 'payload_json returns strings unchanged' do
194
+ payload = '{"ok":true}'
195
+
196
+ assert_equal payload, @curl.send(:payload_json, payload)
197
+ end
198
+
199
+ test 'payload_json encodes hashes as json' do
200
+ assert_equal '{"ok":true}', @curl.send(:payload_json, ok: true)
201
+ end
202
+
203
+ test 'put raises AmbiguousWriteError and does not blind retry' do
204
+ put_uri = URI('http://localhost:5984/test/doc%2F1')
205
+ put_calls = 0
206
+
207
+ @curl.stubs(:perform).with do |method, *_|
208
+ put_calls += 1 if method.to_sym == :put
209
+ true
210
+ end.raises(Curl::Err::RecvError)
211
+ @reader.stubs(:fetch_document).returns(nil)
212
+ @curl.expects(:discard_handle).at_least_once
213
+
214
+ error = assert_raises(Dolly::AmbiguousWriteError) do
215
+ @curl.request(:put, put_uri, { _id: 'doc/1', _rev: '1-abc', foo: 'bar' })
216
+ end
217
+
218
+ assert_equal 1, put_calls
219
+ assert_equal :put, error.method
220
+ assert_equal put_uri, error.uri
221
+ assert_kind_of Curl::Err::RecvError, error.cause
222
+ end
223
+
224
+ test 'put reconciles when committed document matches payload' do
225
+ put_uri = URI('http://localhost:5984/test/doc%2F1')
226
+ payload = { _id: 'doc/1', foo: 'bar' }
227
+ stored = { _id: 'doc/1', _rev: '2-new', foo: 'bar' }
228
+
229
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
230
+ @reader.stubs(:fetch_document).returns(stored)
231
+
232
+ result = @curl.request(:put, put_uri, payload)
233
+
234
+ assert_equal({ ok: true, id: 'doc/1', rev: '2-new' }, result)
235
+ end
236
+
237
+ test 'put retries once when create never landed' do
238
+ put_uri = URI('http://localhost:5984/test/doc%2F1')
239
+ payload = { _id: 'doc/1', foo: 'bar' }
240
+ response = build_curl_response(status: 201, body: '{"ok":true,"id":"doc/1","rev":"1-abc"}')
241
+ put_calls = 0
242
+
243
+ @curl.stubs(:perform).with do |method, *_|
244
+ put_calls += 1 if method.to_sym == :put
245
+ true
246
+ end.raises(Curl::Err::GotNothingError).then.returns(response)
247
+ @reader.stubs(:fetch_document).returns(nil)
248
+
249
+ result = @curl.request(:put, put_uri, payload)
250
+
251
+ assert_equal response, result
252
+ assert_equal 2, put_calls
253
+ end
254
+
255
+ test 'put retries once when source revision is unchanged' do
256
+ put_uri = URI('http://localhost:5984/test/doc%2F1')
257
+ payload = { _id: 'doc/1', _rev: '1-abc', foo: 'baz' }
258
+ stored = { _id: 'doc/1', _rev: '1-abc', foo: 'bar' }
259
+ response = build_curl_response(status: 201, body: '{"ok":true,"id":"doc/1","rev":"2-def"}')
260
+ put_calls = 0
261
+
262
+ @curl.stubs(:perform).with do |method, *_|
263
+ put_calls += 1 if method.to_sym == :put
264
+ true
265
+ end.raises(Curl::Err::PartialFileError).then.returns(response)
266
+ @reader.stubs(:fetch_document).returns(stored)
267
+
268
+ result = @curl.request(:put, put_uri, payload)
269
+
270
+ assert_equal response, result
271
+ assert_equal 2, put_calls
272
+ end
273
+
274
+ test 'put raises AmbiguousWriteError when stored content conflicts' do
275
+ put_uri = URI('http://localhost:5984/test/doc%2F1')
276
+ payload = { _id: 'doc/1', _rev: '1-abc', foo: 'bar' }
277
+ stored = { _id: 'doc/1', _rev: '2-other', foo: 'other' }
278
+
279
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
280
+ @reader.stubs(:fetch_document).returns(stored)
281
+
282
+ assert_raises(Dolly::AmbiguousWriteError) do
283
+ @curl.request(:put, put_uri, payload)
284
+ end
285
+ end
286
+
287
+ test 'attachment put raises AmbiguousWriteError without blind retry' do
288
+ put_uri = URI('http://localhost:5984/test/doc%2F1/attachment.txt')
289
+ put_calls = 0
290
+
291
+ @curl.stubs(:perform).with do |method, *_|
292
+ put_calls += 1 if method.to_sym == :put
293
+ true
294
+ end.raises(Curl::Err::RecvError)
295
+
296
+ assert_raises(Dolly::AmbiguousWriteError) do
297
+ @curl.request(:put, put_uri, { _body: 'payload' })
298
+ end
299
+
300
+ assert_equal 1, put_calls
301
+ end
302
+
303
+ test 'delete reconciles when document is gone' do
304
+ delete_uri = URI('http://localhost:5984/test/doc%2F1?rev=1-abc')
305
+
306
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
307
+ @reader.stubs(:fetch_document).returns(nil)
308
+
309
+ result = @curl.request(:delete, delete_uri, nil)
310
+
311
+ assert_equal({ ok: true, id: 'doc/1' }, result)
312
+ end
313
+
314
+ test 'delete retries once when same revision is still present' do
315
+ delete_uri = URI('http://localhost:5984/test/doc%2F1?rev=1-abc')
316
+ stored = { _id: 'doc/1', _rev: '1-abc', foo: 'bar' }
317
+ response = build_curl_response(status: 200, body: '{"ok":true}')
318
+ delete_calls = 0
319
+
320
+ @curl.stubs(:perform).with do |method, *_|
321
+ delete_calls += 1 if method.to_sym == :delete
322
+ true
323
+ end.raises(Curl::Err::GotNothingError).then.returns(response)
324
+ @reader.stubs(:fetch_document).returns(stored)
325
+
326
+ result = @curl.request(:delete, delete_uri, nil)
327
+
328
+ assert_equal response, result
329
+ assert_equal 2, delete_calls
330
+ end
331
+
332
+ test 'delete raises AmbiguousWriteError when revision changed' do
333
+ delete_uri = URI('http://localhost:5984/test/doc%2F1?rev=1-abc')
334
+ stored = { _id: 'doc/1', _rev: '2-new', foo: 'bar' }
335
+
336
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
337
+ @reader.stubs(:fetch_document).returns(stored)
338
+
339
+ assert_raises(Dolly::AmbiguousWriteError) do
340
+ @curl.request(:delete, delete_uri, nil)
341
+ end
342
+ end
343
+
344
+ test 'unknown write post raises AmbiguousWriteError without blind retry' do
345
+ post_uri = URI('http://localhost:5984/test/_index')
346
+ post_calls = 0
347
+
348
+ @curl.stubs(:perform).with do |method, *_|
349
+ post_calls += 1 if method.to_sym == :post
350
+ true
351
+ end.raises(Curl::Err::RecvError)
352
+
353
+ error = assert_raises(Dolly::AmbiguousWriteError) do
354
+ @curl.request(:post, post_uri, { name: 'demo', index: { fields: ['_id'] } })
355
+ end
356
+
357
+ assert_equal 1, post_calls
358
+ assert_equal :post, error.method
359
+ assert_kind_of Curl::Err::RecvError, error.cause
360
+ end
361
+
362
+ test 'bulk docs reconciles when all documents are already committed' do
363
+ bulk_uri = URI('http://localhost:5984/test/_bulk_docs')
364
+ docs = [
365
+ { _id: 'doc/1', name: 'a' },
366
+ { _id: 'doc/2', name: 'b' }
367
+ ]
368
+ post_calls = 0
369
+
370
+ @curl.stubs(:perform).with do |method, uri, *_|
371
+ post_calls += 1 if method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
372
+ true
373
+ end.raises(Curl::Err::RecvError)
374
+ @reader.stubs(:fetch_documents_by_ids).returns(
375
+ 'doc/1' => { id: 'doc/1', doc: { _id: 'doc/1', _rev: '1-a', name: 'a' } },
376
+ 'doc/2' => { id: 'doc/2', doc: { _id: 'doc/2', _rev: '1-b', name: 'b' } }
377
+ )
378
+
379
+ result = @curl.request(:post, bulk_uri, { docs: docs })
380
+
381
+ assert_equal(
382
+ [
383
+ { ok: true, id: 'doc/1', rev: '1-a' },
384
+ { ok: true, id: 'doc/2', rev: '1-b' }
385
+ ],
386
+ result
387
+ )
388
+ assert_equal 1, post_calls
389
+ end
390
+
391
+ test 'bulk docs retries only unapplied documents' do
392
+ bulk_uri = URI('http://localhost:5984/test/_bulk_docs')
393
+ docs = [
394
+ { _id: 'doc/1', name: 'a' },
395
+ { _id: 'doc/2', name: 'b' }
396
+ ]
397
+ pending_payloads = []
398
+ retry_response = build_curl_response(
399
+ status: 200,
400
+ body: '[{"ok":true,"id":"doc/2","rev":"1-b"}]'
401
+ )
402
+
403
+ @curl.stubs(:perform).with do |method, uri, data, *_|
404
+ if method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
405
+ pending_payloads << data
406
+ raise Curl::Err::RecvError if pending_payloads.size == 1
407
+ end
408
+ true
409
+ end.returns(retry_response)
410
+ @reader.stubs(:fetch_documents_by_ids).returns(
411
+ 'doc/1' => { id: 'doc/1', doc: { _id: 'doc/1', _rev: '1-a', name: 'a' } },
412
+ 'doc/2' => { id: 'doc/2', error: 'not_found' }
413
+ )
414
+
415
+ result = @curl.request(:post, bulk_uri, { docs: docs })
416
+
417
+ assert_equal [{ docs: [{ _id: 'doc/2', name: 'b' }] }], pending_payloads.drop(1)
418
+ assert_equal(
419
+ [
420
+ { ok: true, id: 'doc/1', rev: '1-a' },
421
+ { ok: true, id: 'doc/2', rev: '1-b' }
422
+ ],
423
+ result
424
+ )
425
+ end
426
+
427
+ test 'bulk docs raises AmbiguousWriteError on conflicting content' do
428
+ bulk_uri = URI('http://localhost:5984/test/_bulk_docs')
429
+ docs = [{ _id: 'doc/1', name: 'a' }]
430
+
431
+ @curl.stubs(:perform).raises(Curl::Err::RecvError)
432
+ @reader.stubs(:fetch_documents_by_ids).returns(
433
+ 'doc/1' => { id: 'doc/1', doc: { _id: 'doc/1', _rev: '1-a', name: 'other' } }
434
+ )
435
+
436
+ assert_raises(Dolly::AmbiguousWriteError) do
437
+ @curl.request(:post, bulk_uri, { docs: docs })
438
+ end
439
+ end
440
+
441
+ private
442
+
443
+ def build_curl_response(status:, body:, headers: '')
444
+ stub(status: status.to_s, body_str: body, header_str: headers)
445
+ end
446
+
447
+ def clear_thread_local_curl!
448
+ store = Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY]
449
+ return unless store
450
+
451
+ store.each_value { |handle| handle.close rescue nil }
452
+ store.clear
453
+ Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY] = nil
454
+ end
455
+ end
@@ -32,7 +32,7 @@ class MangoIndexTest < Test::Unit::TestCase
32
32
  ]}.to_json)
33
33
  end
34
34
 
35
- test '#create_in_database' do
35
+ test '#create_in_database skips when migrations are disabled' do
36
36
  assert_equal "Migrations for design_skipped skiped.", Dolly::MangoIndex.create_in_database(
37
37
  :design_skipped,
38
38
  "demo",
@@ -40,7 +40,7 @@ class MangoIndexTest < Test::Unit::TestCase
40
40
  )
41
41
  end
42
42
 
43
- test '#create_in_database' do
43
+ test '#create_in_database posts index to database' do
44
44
  stub_request(:post, "http://localhost:5984/test/_index").
45
45
  to_return(status: 200, body: "Design doc run", headers: {})
46
46
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dolly
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.5
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - javierg
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-10 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: oj
@@ -28,16 +28,16 @@ dependencies:
28
28
  name: curb
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - '='
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.9.8
33
+ version: '1.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - '='
38
+ - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.9.8
40
+ version: '1.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: bundler
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -122,7 +122,8 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
- description: 'couch adapter '
125
+ description: CouchDB adapter for Ruby with thread-local Curb connections, stale keep-alive
126
+ recovery, and safe write reconciliation.
126
127
  email:
127
128
  - javierg@amcoonline.net
128
129
  executables: []
@@ -137,6 +138,13 @@ files:
137
138
  - lib/dolly/collection.rb
138
139
  - lib/dolly/configuration.rb
139
140
  - lib/dolly/connection.rb
141
+ - lib/dolly/curl.rb
142
+ - lib/dolly/curl/connection.rb
143
+ - lib/dolly/curl/document_helper.rb
144
+ - lib/dolly/curl/reader.rb
145
+ - lib/dolly/curl/response_formatter.rb
146
+ - lib/dolly/curl/stale_connection.rb
147
+ - lib/dolly/curl/write_reconciler.rb
140
148
  - lib/dolly/depracated_database.rb
141
149
  - lib/dolly/document.rb
142
150
  - lib/dolly/document_creation.rb
@@ -164,6 +172,8 @@ files:
164
172
  - lib/refinements/string_refinements.rb
165
173
  - lib/tasks/db.rake
166
174
  - test/bulk_document_test.rb
175
+ - test/connection_test.rb
176
+ - test/curl/connection_test.rb
167
177
  - test/document_test.rb
168
178
  - test/document_type_test.rb
169
179
  - test/inheritance_test.rb
@@ -178,7 +188,7 @@ files:
178
188
  homepage: https://www.amco.me
179
189
  licenses: []
180
190
  metadata: {}
181
- post_install_message:
191
+ post_install_message:
182
192
  rdoc_options: []
183
193
  require_paths:
184
194
  - lib
@@ -193,10 +203,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
193
203
  - !ruby/object:Gem::Version
194
204
  version: '0'
195
205
  requirements: []
196
- rubygems_version: 3.1.6
197
- signing_key:
206
+ rubygems_version: 3.2.0
207
+ signing_key:
198
208
  specification_version: 4
199
- summary: will write something
209
+ summary: CouchDB adapter for Ruby
200
210
  test_files:
201
211
  - test/partitioned_document_test.rb
202
212
  - test/slugable_document_test.rb
@@ -205,8 +215,10 @@ test_files:
205
215
  - test/mango_index_test.rb
206
216
  - test/bulk_document_test.rb
207
217
  - test/view_query_test.rb
218
+ - test/curl/connection_test.rb
208
219
  - test/mango_test.rb
209
220
  - test/support/test.txt
221
+ - test/connection_test.rb
210
222
  - test/test_helper.rb
211
223
  - test/document_test.rb
212
224
  - test/property_manager_test.rb