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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +26 -1
- data/lib/e621/csv_importable.rb +35 -4
- data/lib/e621_export_downloader/client/options/builder.rb +8 -0
- data/lib/e621_export_downloader/client/options.rb +2 -0
- data/lib/e621_export_downloader/client.rb +34 -2
- data/lib/e621_export_downloader/export.rb +46 -28
- data/lib/e621_export_downloader/version.rb +1 -1
- data/sorbet/rbi/shims/faraday.rbi +6 -0
- data/test/csv_importable_postgres_test.rb +38 -0
- data/test/retry_test.rb +178 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34abf6e64f79dc2cac589e527e3fcba32308ed0820f874bde23db2cc33b3eed0
|
|
4
|
+
data.tar.gz: 68fb4568601f98b9a597cb429937d9c11703158c92f32034302db9adf8b1d4fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69abf12cfa989fd2ea8196b6a471c6d872363922a1d8f308335d27374bd4f022c23c460663d52fa33f26643e0b41b0f0aeb106167e347c644e31ddb0df888146
|
|
7
|
+
data.tar.gz: 0afe87bb30bd8403e2402010ad329f3746e3b83b93a419f30ecabef6255e6f66c1188f00a95bbc65189ec866cd16efbdbf397d58ded81cab9fd16dff2314f83d
|
data/CHANGELOG.md
CHANGED
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.
|
data/lib/e621/csv_importable.rb
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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")
|
|
@@ -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
|
data/test/retry_test.rb
ADDED
|
@@ -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.
|
|
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-
|
|
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
|