e621_export_downloader 0.0.19 → 0.0.20

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: c5d969922b067f0eb6f92d7f73497105d17a2d228b86a34931f24eaed48c2312
4
- data.tar.gz: f85056aa89a468b019ae03585b53470d0b1c1d8d31c335f1c47e000e7727c743
3
+ metadata.gz: 34abf6e64f79dc2cac589e527e3fcba32308ed0820f874bde23db2cc33b3eed0
4
+ data.tar.gz: 68fb4568601f98b9a597cb429937d9c11703158c92f32034302db9adf8b1d4fa
5
5
  SHA512:
6
- metadata.gz: df8d73bb8eaea951cb41e26c0384647f5219ec71518962986d88a014ded3592454e8ea64bf07c2f4acf44a23b42de0e47b1bbda8134dd42631481ccafb6a3615
7
- data.tar.gz: c7fce51d4283904e52f3defd64fa1de02a4a624a52983cd571123a9124349f4b71edb934d40f7f68d34dd4bbb28c580b28bc423fd221413082771c0d91ec12e5
6
+ metadata.gz: 69abf12cfa989fd2ea8196b6a471c6d872363922a1d8f308335d27374bd4f022c23c460663d52fa33f26643e0b41b0f0aeb106167e347c644e31ddb0df888146
7
+ data.tar.gz: 0afe87bb30bd8403e2402010ad329f3746e3b83b93a419f30ecabef6255e6f66c1188f00a95bbc65189ec866cd16efbdbf397d58ded81cab9fd16dff2314f83d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.20] - 2026-08-21
4
+
5
+ _0.0.20_
6
+
7
+ ## [0.0.19] - 2026-08-03
8
+
9
+ _0.0.19_
10
+
3
11
  ## [0.0.18] - 2026-07-02
4
12
 
5
13
  _0.0.18_
data/README.md CHANGED
@@ -25,11 +25,13 @@ client = E621ExportDownloader::Client.new
25
25
  client.config do |c|
26
26
  c.cache = true # keep export files after reading, defaults to false
27
27
  c.validate_checksum = true # verify the downloaded file against the API-provided checksum, defaults to false
28
+ c.retries = 3 # number of times to retry a request after a transient network error (DNS failure, connection reset, timeout, ...), defaults to 3
29
+ c.retry_delay = 1 # base delay in seconds between retries, multiplied by the attempt number, defaults to 1
28
30
  end
29
31
 
30
32
  # or pass an Options struct directly
31
33
  client = E621ExportDownloader::Client.new(
32
- E621ExportDownloader::Client::Options.new(cache: true, validate_checksum: true)
34
+ E621ExportDownloader::Client::Options.new(cache: true, validate_checksum: true, retries: 3, retry_delay: 1)
33
35
  )
34
36
 
35
37
  # get the export for a type — resolves the latest available export from the e621 API
@@ -212,6 +214,29 @@ require("e621_export_downloader/serializers/active_job")
212
214
  ActiveJob::Serializers.add_serializers(E621ExportDownloader::Serializers::ActiveJob)
213
215
  ```
214
216
 
217
+ ## Notifications
218
+
219
+ The gem instruments [`ActiveSupport::Notifications`](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) events around its major operations, so you can observe/track progress without passing callback params through every call site:
220
+
221
+ | Event | Fired around | Payload |
222
+ | ------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- |
223
+ | `download.e621_export_downloader` | `Export#download`'s network fetch (not a cache hit) | `type`, `file_size`, `validate_checksum`; on failure, `exception`/`exception_object` (standard `ActiveSupport::Notifications` behavior) are set |
224
+ | `retry.e621_export_downloader` | Each retried attempt in `Client#with_retries` | `header`, `attempt`, `error`, `message`, `delay` — not fired for the final, re-raised failure |
225
+ | `truncate.e621_export_downloader` | `import_from_csv`'s `TRUNCATE`, if `truncate: true` | `table` |
226
+ | `drop_index.e621_export_downloader` | Each dropped index, if `recreate_indexes: true` | `table`, `index` |
227
+ | `copy.e621_export_downloader` | The `COPY` itself | `table`; `rows` once it finishes |
228
+ | `create_index.e621_export_downloader` | Each rebuilt index, if `recreate_indexes: true` | `table`, `index` |
229
+
230
+ ```ruby
231
+ require("active_support/notifications")
232
+
233
+ ActiveSupport::Notifications.subscribe("create_index.e621_export_downloader") do |*, payload|
234
+ puts("rebuilt #{payload[:index]} on #{payload[:table]}")
235
+ end
236
+ ```
237
+
238
+ `create_index`/`drop_index` are what you want for a live "N of M indexes done" progress counter — Postgres itself only exposes progress for whichever *one* index is currently mid-build (`pg_stat_progress_create_index`), not how many of a batch have finished.
239
+
215
240
  ## Contributing
216
241
 
217
242
  Bug reports and pull requests are welcome on GitHub at https://github.com/DonovanDMC/E621ExportDownloader.rb.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: true
3
3
 
4
+ require("active_support/notifications")
5
+
4
6
  module E621
5
7
  module CsvImportable
6
8
  extend(T::Sig)
@@ -34,6 +36,21 @@ module E621
34
36
  # Physical-storage policy (UNLOGGED) and session tuning
35
37
  # (maintenance_work_mem, max_parallel_maintenance_workers, ...) are the
36
38
  # caller's responsibility — set them around this call.
39
+ #
40
+ # Every phase below instruments an ActiveSupport::Notifications event
41
+ # (payload always includes at least table:) rather than accepting
42
+ # callback params directly, so any number of callers can subscribe
43
+ # without this method's signature having to grow one param per phase
44
+ # someone wants to observe:
45
+ # "truncate.e621_export_downloader" — around the TRUNCATE, if truncate: true
46
+ # "drop_index.e621_export_downloader" — once per dropped index, payload also has index:
47
+ # "copy.e621_export_downloader" — around the COPY itself, payload also gets rows: once it finishes
48
+ # "create_index.e621_export_downloader" — once per rebuilt index, payload also has index:
49
+ # A subscriber wanting a live "N of M indexes done" progress counter
50
+ # should use create_index (or drop_index, for the teardown half) —
51
+ # Postgres itself only exposes progress for whichever *one* index is
52
+ # currently mid-build, via pg_stat_progress_create_index, not how many
53
+ # of a batch have finished.
37
54
  sig do
38
55
  params(
39
56
  csv_path: String,
@@ -46,13 +63,27 @@ module E621
46
63
  model = T.unsafe(self)
47
64
  indexes = recreate_indexes ? secondary_index_definitions : {}
48
65
 
49
- model.connection.execute("TRUNCATE #{model.quoted_table_name}") if truncate
50
- indexes.each_key { |name| model.connection.execute("DROP INDEX IF EXISTS #{name}") }
66
+ if truncate
67
+ ActiveSupport::Notifications.instrument("truncate.e621_export_downloader", table: model.table_name) do
68
+ model.connection.execute("TRUNCATE #{model.quoted_table_name}")
69
+ end
70
+ end
71
+ indexes.each_key do |name|
72
+ ActiveSupport::Notifications.instrument("drop_index.e621_export_downloader", table: model.table_name, index: name) do
73
+ model.connection.execute("DROP INDEX IF EXISTS #{name}")
74
+ end
75
+ end
51
76
 
52
77
  count = begin
53
- copy_csv(csv_path, chunk_bytes: chunk_bytes)
78
+ ActiveSupport::Notifications.instrument("copy.e621_export_downloader", table: model.table_name) do |payload|
79
+ copy_csv(csv_path, chunk_bytes: chunk_bytes).tap { |rows| payload[:rows] = rows }
80
+ end
54
81
  ensure
55
- indexes.each_value { |ddl| model.connection.execute(ddl) }
82
+ indexes.each do |name, ddl|
83
+ ActiveSupport::Notifications.instrument("create_index.e621_export_downloader", table: model.table_name, index: name) do
84
+ model.connection.execute(ddl)
85
+ end
86
+ end
56
87
  end
57
88
  self.row_count = count
58
89
  count
@@ -13,6 +13,12 @@ module E621ExportDownloader
13
13
  sig { returns(T::Boolean) }
14
14
  attr_accessor(:validate_checksum)
15
15
 
16
+ sig { returns(Integer) }
17
+ attr_accessor(:retries)
18
+
19
+ sig { returns(Numeric) }
20
+ attr_accessor(:retry_delay)
21
+
16
22
  sig { params(parsers: Options::Parsers).returns(Options::Parsers) }
17
23
  attr_writer(:parsers)
18
24
 
@@ -21,6 +27,8 @@ module E621ExportDownloader
21
27
  @cache = T.let(defaults.cache, T::Boolean)
22
28
  @validate_checksum = T.let(defaults.validate_checksum, T::Boolean)
23
29
  @parsers = T.let(defaults.parsers, Options::Parsers)
30
+ @retries = T.let(defaults.retries, Integer)
31
+ @retry_delay = T.let(defaults.retry_delay, Numeric)
24
32
  end
25
33
 
26
34
  sig { params(block: T.nilable(T.proc.params(arg0: Builder::Parsers).void)).returns(Options::Parsers) }
@@ -40,6 +40,8 @@ module E621ExportDownloader
40
40
  const(:cache, T::Boolean, default: false)
41
41
  const(:validate_checksum, T::Boolean, default: false)
42
42
  const(:parsers, Parsers, default: Parsers.defaults)
43
+ const(:retries, Integer, default: 3)
44
+ const(:retry_delay, Numeric, default: 1)
43
45
  end
44
46
  end
45
47
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: strict
3
3
 
4
+ require("active_support/notifications")
4
5
  require("faraday")
5
6
  require("json")
6
7
 
@@ -10,6 +11,9 @@ module E621ExportDownloader
10
11
 
11
12
  Logger = ::Logger.new($stdout)
12
13
 
14
+ # transient network failures (DNS resolution, connection resets, timeouts, ...) worth retrying
15
+ RETRYABLE_ERRORS = T.let([Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze, T::Array[T.class_of(Faraday::Error)])
16
+
13
17
  sig { returns(Options) }
14
18
  attr_reader(:options)
15
19
 
@@ -23,7 +27,13 @@ module E621ExportDownloader
23
27
  def config(&block)
24
28
  builder = Options::Builder.new(@options)
25
29
  block.call(builder)
26
- @options = Options.new(cache: builder.cache, validate_checksum: builder.validate_checksum, parsers: builder.parsers)
30
+ @options = Options.new(
31
+ cache: builder.cache,
32
+ validate_checksum: builder.validate_checksum,
33
+ parsers: builder.parsers,
34
+ retries: builder.retries,
35
+ retry_delay: builder.retry_delay,
36
+ )
27
37
  end
28
38
 
29
39
  sig { returns(Faraday::Connection) }
@@ -36,6 +46,28 @@ module E621ExportDownloader
36
46
  Logger.debug("[e621_export_downloader#{":#{header.join(':')}" unless header.empty?}] #{msg}")
37
47
  end
38
48
 
49
+ # Fires "retry.e621_export_downloader" (payload: {header:, attempt:, error:,
50
+ # message:, delay:}) once per retried attempt — nothing else in the gem
51
+ # otherwise surfaces transient network failures beyond a debug log line,
52
+ # so a caller wanting to alert/count on flaky-connection retries has
53
+ # somewhere to hook in. Not fired on the final, re-raised failure — by
54
+ # that point it's an ordinary exception the caller already sees directly.
55
+ sig { params(header: T::Array[String], block: T.proc.returns(T.untyped)).returns(T.untyped) }
56
+ def with_retries(header: [], &block)
57
+ attempts = 0
58
+ begin
59
+ attempts += 1
60
+ block.call
61
+ rescue *RETRYABLE_ERRORS => e
62
+ raise if attempts > options.retries
63
+ delay = options.retry_delay * attempts
64
+ debug("attempt #{attempts} failed (#{e.class}: #{e.message}), retrying in #{delay}s", header: header)
65
+ ActiveSupport::Notifications.instrument("retry.e621_export_downloader", header: header, attempt: attempts, error: e.class.name, message: e.message, delay: delay)
66
+ sleep(delay)
67
+ retry
68
+ end
69
+ end
70
+
39
71
  sig { params(type: T.any(Types, String)).returns(Export[T.untyped]) }
40
72
  def get(type)
41
73
  type = string_to_type(type) if type.is_a?(String)
@@ -58,7 +90,7 @@ module E621ExportDownloader
58
90
  def get_data
59
91
  return @export_cache unless @export_cache.nil?
60
92
  debug("fetching export data from api")
61
- res = connection.get(Constants::API_URL)
93
+ res = with_retries(header: ["export_data"]) { connection.get(Constants::API_URL) }
62
94
  raise(ResolveError, "Failed to fetch exports: #{res.status} #{res.reason_phrase}") unless res.success?
63
95
  data = JSON.parse(T.unsafe(res).body)
64
96
  result = T.let(data.map do |d|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: strict
3
3
 
4
+ require("active_support/notifications")
4
5
  require("csv")
5
6
  require("date")
6
7
  require("digest")
@@ -44,6 +45,14 @@ module E621ExportDownloader
44
45
  true
45
46
  end
46
47
 
48
+ # Instruments "download.e621_export_downloader" (payload: {type:, file_size:,
49
+ # validate_checksum:}) around the actual network fetch — not the cached-file
50
+ # early return above, so a subscriber only ever sees this fire for a real
51
+ # download. Any error (a failed response, a checksum mismatch, exhausted
52
+ # retries, ...) propagates normally; ActiveSupport::Notifications still
53
+ # publishes the event when the block raises, with the exception attached
54
+ # to the payload, so a subscriber doesn't need its own rescue to notice a
55
+ # failed download.
47
56
  sig { params(block: T.nilable(T.proc.params(received: Integer, total: Integer).void)).returns(String) }
48
57
  def download(&block)
49
58
  raise(ResolveError, "Export #{type.serialize} does not exist") unless exists?
@@ -55,33 +64,9 @@ module E621ExportDownloader
55
64
  client.debug("downloading export", header: ["export:#{type.serialize}"])
56
65
 
57
66
  FileUtils.mkdir_p(Constants::TEMP_DIR)
58
- digest = T.let(client.options.validate_checksum ? Digest::SHA256.new : nil, T.nilable(Digest::SHA256))
59
- File.open(file_path, "wb") do |file|
60
- inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
61
-
62
- res = client.connection.get(data.url) do |req|
63
- req.options.on_data = proc do |chunk, received|
64
- digest&.update(chunk)
65
- decompressed = inflater.inflate(chunk)
66
- file.write(decompressed) if decompressed && !decompressed.empty?
67
- block.call(received, data.file_size) if block
68
- end
69
- end
70
-
71
- file.write(inflater.finish)
72
- inflater.close
73
- file.close
74
-
75
- unless res.success?
76
- raise(ResolveError, "Failed to download export #{type.serialize}: #{res.status} #{res.reason_phrase}")
77
- end
78
-
79
- if digest && digest.hexdigest != data.checksum
80
- raise(ResolveError, "Checksum mismatch for export #{type.serialize}")
81
- end
82
- rescue # rubocop:disable Style/RescueStandardError
83
- FileUtils.rm_f(file_path)
84
- raise
67
+ payload = { type: type.serialize, file_size: data.file_size, validate_checksum: client.options.validate_checksum }
68
+ ActiveSupport::Notifications.instrument("download.e621_export_downloader", payload) do
69
+ client.with_retries(header: ["export:#{type.serialize}"]) { perform_download(&block) }
85
70
  end
86
71
 
87
72
  @downloaded = true
@@ -91,7 +76,8 @@ module E621ExportDownloader
91
76
 
92
77
  sig { returns(T::Boolean) }
93
78
  def exists?
94
- result = client.connection.head(data.url).success?
79
+ res = client.with_retries(header: ["export:#{type.serialize}"]) { client.connection.head(data.url) }
80
+ result = res.success?
95
81
  client.debug("checked export existence: #{result}", header: ["export:#{type.serialize}"])
96
82
  result
97
83
  end
@@ -122,6 +108,38 @@ module E621ExportDownloader
122
108
 
123
109
  private
124
110
 
111
+ sig { params(block: T.nilable(T.proc.params(received: Integer, total: Integer).void)).void }
112
+ def perform_download(&block)
113
+ digest = T.let(client.options.validate_checksum ? Digest::SHA256.new : nil, T.nilable(Digest::SHA256))
114
+ File.open(file_path, "wb") do |file|
115
+ inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
116
+
117
+ res = client.connection.get(data.url) do |req|
118
+ req.options.on_data = proc do |chunk, received|
119
+ digest&.update(chunk)
120
+ decompressed = inflater.inflate(chunk)
121
+ file.write(decompressed) if decompressed && !decompressed.empty?
122
+ block.call(received, data.file_size) if block
123
+ end
124
+ end
125
+
126
+ file.write(inflater.finish)
127
+ inflater.close
128
+ file.close
129
+
130
+ unless res.success?
131
+ raise(ResolveError, "Failed to download export #{type.serialize}: #{res.status} #{res.reason_phrase}")
132
+ end
133
+
134
+ if digest && digest.hexdigest != data.checksum
135
+ raise(ResolveError, "Checksum mismatch for export #{type.serialize}")
136
+ end
137
+ rescue # rubocop:disable Style/RescueStandardError
138
+ FileUtils.rm_f(file_path)
139
+ raise
140
+ end
141
+ end
142
+
125
143
  sig { returns(String) }
126
144
  def format_date
127
145
  DateTime.parse(data.updated_at).strftime("%Y-%m-%d")
@@ -4,7 +4,7 @@
4
4
  # loaded by bundler
5
5
  module E621ExportDownloader
6
6
  module Constants
7
- VERSION = "0.0.19"
7
+ VERSION = "0.0.20"
8
8
  WEBSITE = "https://github.com/DonovanDMC/E621ExportDownloader.rb"
9
9
  end
10
10
  end
@@ -1,6 +1,12 @@
1
1
  # typed: true
2
2
 
3
3
  module Faraday
4
+ class Error < StandardError; end
5
+ class ServerError < Error; end
6
+ class ConnectionFailed < Error; end
7
+ class TimeoutError < ServerError; end
8
+ class SSLError < Error; end
9
+
4
10
  class Request
5
11
  sig { returns(Symbol) }
6
12
  def http_method; end
@@ -3,6 +3,7 @@
3
3
  require("minitest/autorun")
4
4
  require("tempfile")
5
5
  require("active_record")
6
+ require("active_support/notifications")
6
7
  require("e621_export_downloader")
7
8
  require("e621/csv_importable")
8
9
  require("e621/row_count")
@@ -50,6 +51,43 @@ class CsvImportablePostgresTest < Minitest::Test
50
51
  assert_index_exists
51
52
  end
52
53
 
54
+ def test_recreate_indexes_instruments_create_index_notification_per_index
55
+ events = []
56
+ callback = ->(_name, _start, _finish, _id, payload) { events << payload }
57
+
58
+ ActiveSupport::Notifications.subscribed(callback, "create_index.e621_export_downloader") do
59
+ CsvImportablePostgresTestModel.import_from_csv(csv_file(successful_csv), truncate: true, recreate_indexes: true, chunk_bytes: 8)
60
+ end
61
+
62
+ assert_equal(1, events.size)
63
+ assert_equal("import_index_test_tags", events.first[:table])
64
+ assert_equal(INDEX_NAME, events.first[:index])
65
+ end
66
+
67
+ def test_import_from_csv_instruments_truncate_drop_index_and_copy_notifications
68
+ truncate_events = []
69
+ drop_index_events = []
70
+ copy_events = []
71
+ subscriptions = [
72
+ ActiveSupport::Notifications.subscribe("truncate.e621_export_downloader") { |*, payload| truncate_events << payload },
73
+ ActiveSupport::Notifications.subscribe("drop_index.e621_export_downloader") { |*, payload| drop_index_events << payload },
74
+ ActiveSupport::Notifications.subscribe("copy.e621_export_downloader") { |*, payload| copy_events << payload },
75
+ ]
76
+
77
+ CsvImportablePostgresTestModel.import_from_csv(csv_file(successful_csv), truncate: true, recreate_indexes: true, chunk_bytes: 8)
78
+
79
+ assert_equal(1, truncate_events.size)
80
+ assert_equal("import_index_test_tags", truncate_events.first[:table])
81
+
82
+ assert_equal(1, drop_index_events.size)
83
+ assert_equal(INDEX_NAME, drop_index_events.first[:index])
84
+
85
+ assert_equal(1, copy_events.size)
86
+ assert_equal(2, copy_events.first[:rows])
87
+ ensure
88
+ subscriptions&.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
89
+ end
90
+
53
91
  private
54
92
 
55
93
  def setup_schema
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("minitest/autorun")
4
+ require("active_support/notifications")
5
+ require("digest")
6
+ require("fileutils")
7
+ require("zlib")
8
+ require("e621_export_downloader")
9
+
10
+ # A connection double whose #get fails with a transient Faraday error a configurable
11
+ # number of times before succeeding, to exercise the download retry/reset behavior.
12
+ # #head always succeeds so failures are isolated to the streaming GET path.
13
+ class FlakyConnection
14
+ def initialize(fail_times:, chunks: [])
15
+ @fail_times = fail_times
16
+ @get_attempts = 0
17
+ @chunks = chunks
18
+ end
19
+
20
+ def head(_url)
21
+ Struct.new(:success?).new(true)
22
+ end
23
+
24
+ def get(_url)
25
+ @get_attempts += 1
26
+ raise(Faraday::ConnectionFailed, "transient dns failure") if @get_attempts <= @fail_times
27
+
28
+ req = Struct.new(:options).new(Struct.new(:on_data).new)
29
+ yield(req)
30
+
31
+ received = 0
32
+ @chunks.each do |chunk|
33
+ received += chunk.bytesize
34
+ req.options.on_data.call(chunk, received)
35
+ end
36
+
37
+ Struct.new(:status, :reason_phrase, :success?).new(200, "OK", true)
38
+ end
39
+ end
40
+
41
+ class WithRetriesTest < Minitest::Test
42
+ def build_client(retries: 2)
43
+ E621ExportDownloader::Client.new(
44
+ E621ExportDownloader::Client::Options.new(retries: retries, retry_delay: 0),
45
+ )
46
+ end
47
+
48
+ def test_with_retries_recovers_after_transient_failures_within_the_retry_budget
49
+ client = build_client(retries: 2)
50
+ attempts = 0
51
+ result = client.with_retries do
52
+ attempts += 1
53
+ raise(Faraday::ConnectionFailed, "boom") if attempts < 3
54
+ :ok
55
+ end
56
+ assert_equal(:ok, result)
57
+ assert_equal(3, attempts)
58
+ end
59
+
60
+ def test_with_retries_raises_once_the_retry_budget_is_exhausted
61
+ client = build_client(retries: 1)
62
+ attempts = 0
63
+ assert_raises(Faraday::ConnectionFailed) do
64
+ client.with_retries do
65
+ attempts += 1
66
+ raise(Faraday::ConnectionFailed, "boom")
67
+ end
68
+ end
69
+ assert_equal(2, attempts)
70
+ end
71
+
72
+ def test_with_retries_does_not_retry_non_transient_errors
73
+ client = build_client(retries: 5)
74
+ attempts = 0
75
+ assert_raises(ArgumentError) do
76
+ client.with_retries do
77
+ attempts += 1
78
+ raise(ArgumentError, "not a network error")
79
+ end
80
+ end
81
+ assert_equal(1, attempts)
82
+ end
83
+
84
+ def test_with_retries_instruments_a_retry_notification_per_retried_attempt_only
85
+ client = build_client(retries: 2)
86
+ events = []
87
+ callback = ->(_name, _start, _finish, _id, payload) { events << payload }
88
+
89
+ attempts = 0
90
+ ActiveSupport::Notifications.subscribed(callback, "retry.e621_export_downloader") do
91
+ client.with_retries(header: ["test"]) do
92
+ attempts += 1
93
+ raise(Faraday::ConnectionFailed, "boom") if attempts < 3
94
+ :ok
95
+ end
96
+ end
97
+
98
+ assert_equal(2, events.size)
99
+ assert_equal([1, 2], events.map { |e| e[:attempt] })
100
+ assert_equal(["test"], events.first[:header])
101
+ assert_equal("Faraday::ConnectionFailed", events.first[:error])
102
+ end
103
+ end
104
+
105
+ class ExportRetryTest < Minitest::Test
106
+ def setup
107
+ @csv_content = "id,name\n1,foo\n"
108
+ @compressed = Zlib.gzip(@csv_content)
109
+ end
110
+
111
+ def teardown
112
+ FileUtils.rm_rf(E621ExportDownloader::Constants::TEMP_DIR)
113
+ end
114
+
115
+ def build_export(connection, retries: 2)
116
+ data = E621ExportDownloader::APIExportData.new(
117
+ name: E621ExportDownloader::Types::Posts,
118
+ file_name: "posts-retry-test.csv",
119
+ file_size: @compressed.bytesize,
120
+ checksum: Digest::SHA256.hexdigest(@compressed),
121
+ updated_at: "2026-01-01",
122
+ url: "https://example.com/posts.csv.gz",
123
+ )
124
+ client = E621ExportDownloader::Client.new(
125
+ E621ExportDownloader::Client::Options.new(retries: retries, retry_delay: 0),
126
+ )
127
+ client.define_singleton_method(:connection) { connection }
128
+ E621ExportDownloader::Export.new(
129
+ data: data,
130
+ client: client,
131
+ type: E621ExportDownloader::Types::Posts,
132
+ parser: ->(record) { record },
133
+ )
134
+ end
135
+
136
+ def test_download_recovers_from_a_transient_failure_and_produces_a_correct_file
137
+ connection = FlakyConnection.new(fail_times: 1, chunks: [@compressed])
138
+ export = build_export(connection, retries: 2)
139
+ path = export.download
140
+ assert(File.exist?(path))
141
+ assert_equal(@csv_content, File.read(path))
142
+ end
143
+
144
+ def test_download_gives_up_and_raises_after_the_retry_budget_is_exhausted
145
+ connection = FlakyConnection.new(fail_times: 5, chunks: [@compressed])
146
+ export = build_export(connection, retries: 1)
147
+ assert_raises(Faraday::ConnectionFailed) { export.download }
148
+ assert_empty(Dir.children(E621ExportDownloader::Constants::TEMP_DIR))
149
+ end
150
+
151
+ def test_download_instruments_a_download_notification_on_success
152
+ connection = FlakyConnection.new(fail_times: 0, chunks: [@compressed])
153
+ export = build_export(connection, retries: 2)
154
+ events = []
155
+ callback = ->(_name, _start, _finish, _id, payload) { events << payload }
156
+
157
+ ActiveSupport::Notifications.subscribed(callback, "download.e621_export_downloader") { export.download }
158
+
159
+ assert_equal(1, events.size)
160
+ assert_equal("posts", events.first[:type])
161
+ assert_equal(@compressed.bytesize, events.first[:file_size])
162
+ assert_nil(events.first[:exception])
163
+ end
164
+
165
+ def test_download_instruments_a_download_notification_with_the_exception_on_failure
166
+ connection = FlakyConnection.new(fail_times: 5, chunks: [@compressed])
167
+ export = build_export(connection, retries: 1)
168
+ events = []
169
+ callback = ->(_name, _start, _finish, _id, payload) { events << payload }
170
+
171
+ ActiveSupport::Notifications.subscribed(callback, "download.e621_export_downloader") do
172
+ assert_raises(Faraday::ConnectionFailed) { export.download }
173
+ end
174
+
175
+ assert_equal(1, events.size)
176
+ assert_equal("Faraday::ConnectionFailed", events.first[:exception]&.first)
177
+ end
178
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: e621_export_downloader
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.19
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Donovan_DMC
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-03 00:00:00.000000000 Z
10
+ date: 2026-08-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -226,6 +226,7 @@ files:
226
226
  - test/csv_importable_postgres_test.rb
227
227
  - test/export_test.rb
228
228
  - test/generators/install_generator_test.rb
229
+ - test/retry_test.rb
229
230
  homepage: https://github.com/DonovanDMC/E621ExportDownloader.rb
230
231
  licenses:
231
232
  - MIT