e621_export_downloader 0.0.18 → 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: 7259f8077bf1df8805620f4efa3fbeac43c29616e496bea576518275794c5fef
4
- data.tar.gz: 4b23216298e535aeed4ceaeb7f38f581bf6fc6fb68c1f7fa94975e9157f7bf71
3
+ metadata.gz: 34abf6e64f79dc2cac589e527e3fcba32308ed0820f874bde23db2cc33b3eed0
4
+ data.tar.gz: 68fb4568601f98b9a597cb429937d9c11703158c92f32034302db9adf8b1d4fa
5
5
  SHA512:
6
- metadata.gz: 50436457d2de9e91e8f938631484653a71d8102122ec58f77c8c6287454092c38ef6719215a4aa20cb676978d659ce936750338667d892045027b4ee0a0fa0ed
7
- data.tar.gz: b8a0c19646cd5b7ed2076b023adabc016962700dcc4c755ef2b66d3f3b018349be45aafca1c4be1dc8c0be9c8f2dd6de5f1529f6ea27f45a04df5660d6f6f1a9
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,12 +63,28 @@ 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}") }
51
-
52
- count = copy_csv(csv_path, chunk_bytes: chunk_bytes)
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
53
76
 
54
- indexes.each_value { |ddl| model.connection.execute(ddl) }
77
+ count = begin
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
81
+ ensure
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
87
+ end
55
88
  self.row_count = count
56
89
  count
57
90
  end
@@ -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::MD5.new : nil, T.nilable(Digest::MD5))
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.18"
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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("minitest/autorun")
4
+ require("tempfile")
5
+ require("active_record")
6
+ require("active_support/notifications")
7
+ require("e621_export_downloader")
8
+ require("e621/csv_importable")
9
+ require("e621/row_count")
10
+
11
+ class CsvImportablePostgresTestModel < ActiveRecord::Base
12
+ extend(E621::CsvImportable)
13
+
14
+ self.table_name = "import_index_test_tags"
15
+ self.record_timestamps = false
16
+ end
17
+
18
+ class CsvImportablePostgresTest < Minitest::Test
19
+ INDEX_NAME = "index_import_index_test_tags_on_name"
20
+
21
+ def setup
22
+ skip("set E621_TEST_DATABASE_URL to run PostgreSQL import tests") unless ENV["E621_TEST_DATABASE_URL"]
23
+
24
+ ActiveRecord::Base.establish_connection(ENV.fetch("E621_TEST_DATABASE_URL"))
25
+ @tempfiles = []
26
+ setup_schema
27
+ end
28
+
29
+ def teardown
30
+ return unless ENV["E621_TEST_DATABASE_URL"]
31
+
32
+ connection.execute("DROP TABLE IF EXISTS import_index_test_tags")
33
+ connection.execute("DROP TABLE IF EXISTS e621.row_counts")
34
+ @tempfiles&.each(&:unlink)
35
+ end
36
+
37
+ def test_recreate_indexes_restores_postgres_indexes_after_successful_import
38
+ count = CsvImportablePostgresTestModel.import_from_csv(csv_file(successful_csv), truncate: true, recreate_indexes: true, chunk_bytes: 8)
39
+
40
+ assert_equal(2, count)
41
+ assert_equal(2, CsvImportablePostgresTestModel.count)
42
+ assert_index_exists
43
+ end
44
+
45
+ def test_recreate_indexes_restores_postgres_indexes_after_import_error
46
+ error = assert_raises(StandardError) do
47
+ CsvImportablePostgresTestModel.import_from_csv(csv_file(invalid_csv), truncate: true, recreate_indexes: true, chunk_bytes: 8)
48
+ end
49
+
50
+ assert_match(/COPY|missing data|extra data|invalid input/i, error.message)
51
+ assert_index_exists
52
+ end
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
+
91
+ private
92
+
93
+ def setup_schema
94
+ connection.execute("CREATE SCHEMA IF NOT EXISTS e621")
95
+ connection.execute(<<~SQL)
96
+ CREATE TABLE IF NOT EXISTS e621.row_counts (
97
+ table_name varchar PRIMARY KEY,
98
+ count bigint NOT NULL DEFAULT 0
99
+ )
100
+ SQL
101
+ connection.execute("DROP TABLE IF EXISTS import_index_test_tags")
102
+ connection.execute(<<~SQL)
103
+ CREATE TABLE import_index_test_tags (
104
+ id bigint PRIMARY KEY,
105
+ name text NOT NULL,
106
+ post_count integer NOT NULL
107
+ )
108
+ SQL
109
+ connection.execute("CREATE INDEX #{INDEX_NAME} ON import_index_test_tags (name)")
110
+ end
111
+
112
+ def successful_csv
113
+ <<~CSV
114
+ id,name,post_count
115
+ 1,alpha,10
116
+ 2,beta,20
117
+ CSV
118
+ end
119
+
120
+ def invalid_csv
121
+ <<~CSV
122
+ id,name,post_count
123
+ 1,alpha
124
+ CSV
125
+ end
126
+
127
+ def csv_file(content)
128
+ file = Tempfile.new(["e621-import", ".csv"])
129
+ file.write(content)
130
+ file.close
131
+ @tempfiles << file
132
+ file.path
133
+ end
134
+
135
+ def assert_index_exists
136
+ assert(index_exists?, "expected PostgreSQL index #{INDEX_NAME} to exist")
137
+ end
138
+
139
+ def index_exists?
140
+ connection.select_value(<<~SQL)
141
+ SELECT EXISTS (
142
+ SELECT 1
143
+ FROM pg_class c
144
+ JOIN pg_namespace n ON n.oid = c.relnamespace
145
+ WHERE c.relkind = 'i'
146
+ AND c.relname = #{connection.quote(INDEX_NAME)}
147
+ )
148
+ SQL
149
+ end
150
+
151
+ def connection
152
+ ActiveRecord::Base.connection
153
+ end
154
+ end
data/test/export_test.rb CHANGED
@@ -39,7 +39,7 @@ class ExportChecksumTest < Minitest::Test
39
39
  def setup
40
40
  @csv_content = "id,name\n1,foo\n"
41
41
  @compressed = Zlib.gzip(@csv_content)
42
- @checksum = Digest::MD5.hexdigest(@compressed)
42
+ @checksum = Digest::SHA256.hexdigest(@compressed)
43
43
  # split across chunks to exercise streaming, matching how a real download arrives
44
44
  midpoint = @compressed.bytesize / 2
45
45
  @chunks = [@compressed.byteslice(0, midpoint), @compressed.byteslice(midpoint..)]
@@ -79,14 +79,14 @@ class ExportChecksumTest < Minitest::Test
79
79
  end
80
80
 
81
81
  def test_download_raises_and_removes_the_file_when_checksum_does_not_match
82
- export = build_export(checksum: "0" * 32, validate_checksum: true)
82
+ export = build_export(checksum: "0" * 64, validate_checksum: true)
83
83
  error = assert_raises(E621ExportDownloader::ResolveError) { export.download }
84
84
  assert_match(/Checksum mismatch/, error.message)
85
85
  assert_empty(Dir.children(E621ExportDownloader::Constants::TEMP_DIR))
86
86
  end
87
87
 
88
88
  def test_download_ignores_checksum_mismatch_when_validation_is_disabled
89
- export = build_export(checksum: "0" * 32, validate_checksum: false)
89
+ export = build_export(checksum: "0" * 64, validate_checksum: false)
90
90
  path = export.download
91
91
  assert(File.exist?(path))
92
92
  end
@@ -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.18
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-07-05 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
@@ -223,8 +223,10 @@ files:
223
223
  - sorbet/rbi/todo.rbi
224
224
  - sorbet/tapioca/config.yml
225
225
  - sorbet/tapioca/require.rb
226
+ - test/csv_importable_postgres_test.rb
226
227
  - test/export_test.rb
227
228
  - test/generators/install_generator_test.rb
229
+ - test/retry_test.rb
228
230
  homepage: https://github.com/DonovanDMC/E621ExportDownloader.rb
229
231
  licenses:
230
232
  - MIT