Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[supply] parallel uploads for meta per language #21474

Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 6 additions & 2 deletions fastlane/lib/fastlane/actions/docs/upload_to_play_store.md
Expand Up @@ -179,6 +179,12 @@ This can be done using the `--track_promote_to` parameter. The `--track_promote_
Before performing a new APK upload you may want to check existing track version codes or release names, or you may simply want to provide an informational lane that displays the currently promoted version codes or release name for the production track. You can use the `google_play_track_version_codes` action to retrieve existing version codes for a package and track. You can use the `google_play_track_release_names` action to retrieve existing release names for a package and track.
For more information, see the `fastlane action google_play_track_version_codes` and `fastlane action google_play_track_release_names` help output.

## Parallel uploads

By default _supply_ will spawn 10 threads to upload the metadata concurrently (_images, screenshots, texts_). If you want to change this, set either `DELIVER_NUMBER_OF_THREADS` or `FL_NUMBER_OF_THREADS` environment variable to any value between 1 and 10.

If you want _supply_ to upload with more than 10 threads in parallel then you need to **additionally** set `FL_MAX_NUMBER_OF_THREADS` environment variable to the max number of parallel upload threads you wish to have (**Warning ⚠️** use this at your own risk!).

## Migration from AndroidPublisherV2 to AndroidPublisherV3 in _fastlane_ 2.135.0

### New Options
Expand All @@ -200,5 +206,3 @@ For more information, see the `fastlane action google_play_track_version_codes`
- Google Play will automatically remove releases that are superseded now
- `:deactivate_on_promote`
- Google Play will automatically deactivate a release from its previous track on promote

:
2 changes: 1 addition & 1 deletion fastlane_core/lib/fastlane_core/queue_worker.rb
Expand Up @@ -6,7 +6,7 @@ module FastlaneCore
# Use this when you have all the items that you'll process in advance.
# Simply enqueue them to this and call `QueueWorker#start`.
class QueueWorker
NUMBER_OF_THREADS = FastlaneCore::Helper.test? ? 1 : [(ENV["DELIVER_NUMBER_OF_THREADS"] || ENV.fetch("FL_NUMBER_OF_THREADS", 10)).to_i, 10].min
NUMBER_OF_THREADS = FastlaneCore::Helper.test? ? 1 : [ENV["DELIVER_NUMBER_OF_THREADS"], ENV["FL_NUMBER_OF_THREADS"], 10].map(&:to_i).find(&:positive?).clamp(1, ENV.fetch("FL_MAX_NUMBER_OF_THREADS", 10).to_i)

# @param concurrency (Numeric) - A number of threads to be created
# @param block (Proc) - A task you want to execute with enqueued items
Expand Down
39 changes: 27 additions & 12 deletions supply/lib/supply/uploader.rb
@@ -1,6 +1,10 @@
require 'fastlane_core'

module Supply
# rubocop:disable Metrics/ClassLength
class Uploader
UploadJob = Struct.new(:language, :version_code, :release_notes)

def perform_upload
FastlaneCore::PrintTable.print_values(config: Supply.config, hide_keys: [:issuer], mask_keys: [:json_key_data], title: "Summary for supply #{Fastlane::VERSION}")

Expand Down Expand Up @@ -88,19 +92,15 @@ def perform_upload_meta(version_codes, track_name)
UI.user_error!("Unable to find the requested track - '#{Supply.config[:track]}'") unless track
UI.user_error!("Could not find release for version code '#{version_code}' to update changelog") unless release

release_notes = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One difference in behavior before/after this change is that before, release_notes was reset to [] on each iteration of the version_codes loop, while now it's not, and thus is common to all version_codes.

That might break the logic (and potentially mix up the release notes published when there are different version codes with different changelogs/*.txt files.

You might thus want to keep this reset of release_notes = [] inside the loop to cover for this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely observed 😄 I haven't been testing the multi version codes scenario. Will fix.

all_languages.each do |language|
next if language.start_with?('.') # e.g. . or .. or hidden folders
UI.message("Preparing to upload for language '#{language}'...")

listing = client.listing_for_language(language)

upload_metadata(language, listing) unless Supply.config[:skip_upload_metadata]
upload_images(language) unless Supply.config[:skip_upload_images]
upload_screenshots(language) unless Supply.config[:skip_upload_screenshots]
release_notes << upload_changelog(language, version_code) unless Supply.config[:skip_upload_changelogs]
end
release_notes = Queue.new
upload_worker = create_meta_upload_worker
upload_worker.batch_enqueue(
# skip . or .. or hidden folders
all_languages.reject { |lang| lang.start_with?('.') }.map { |lang| UploadJob.new(lang, version_code, release_notes) }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.reject behaves the same as the previously used next if

)
upload_worker.start

release_notes = Array.new(release_notes.size) { release_notes.pop } # Queue to Array
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan or reusing the same variable name for two different things. Maybe rename the original variable release_notes_queue = Queue.new and then use release_notes = Array.new(release_notes_queue.size) { release_notes_queue.pop }? (And for consistency, maybe name the attribute in UploadJob as :release_notes_queue instead of :release_notes too?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense. will change to release_notes_queue for clarity 👌🏻

upload_changelogs(release_notes, release, track, track_name) unless release_notes.empty?
end
end
Expand Down Expand Up @@ -511,6 +511,21 @@ def obb_expansion_file_type(obb_file_path)
'patch'
end
end

def create_meta_upload_worker
FastlaneCore::QueueWorker.new do |job|
UI.message("Preparing uploads for language '#{job.language}'...")
start_time = Time.now
listing = client.listing_for_language(job.language)
upload_metadata(job.language, listing) unless Supply.config[:skip_upload_metadata]
upload_images(job.language) unless Supply.config[:skip_upload_images]
upload_screenshots(job.language) unless Supply.config[:skip_upload_screenshots]
job.release_notes << upload_changelog(job.language, job.version_code) unless Supply.config[:skip_upload_changelogs]
UI.message("Uploaded all items for language '#{job.language}'... (#{Time.now - start_time} secs)")
rescue => error
UI.abort_with_message!("#{job.language} - #{error}")
end
end
end
# rubocop:enable Metrics/ClassLength
end
Empty file.
@@ -0,0 +1 @@
en-US changelog 1
@@ -0,0 +1 @@
en-US changelog 2
@@ -0,0 +1 @@
en-US changelog -1
@@ -0,0 +1 @@
en-US full description
@@ -0,0 +1 @@
en-US short description
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
en-US title
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/en-US/video.txt
@@ -0,0 +1 @@
en-US video
Empty file.
@@ -0,0 +1 @@
fr-FR changelog 1
@@ -0,0 +1 @@
fr-FR changelog 2
@@ -0,0 +1 @@
fr-FR changelog -1
@@ -0,0 +1 @@
fr-FR full description
@@ -0,0 +1 @@
fr-FR short description
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/fr-FR/title.txt
@@ -0,0 +1 @@
fr-FR title
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/fr-FR/video.txt
@@ -0,0 +1 @@
fr-FR video
Empty file.
@@ -0,0 +1 @@
ja-JP changelog 1
@@ -0,0 +1 @@
ja-JP changelog 2
@@ -0,0 +1 @@
ja-JP changelog -1
@@ -0,0 +1 @@
ja-JP full description
@@ -0,0 +1 @@
ja-JP short description
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/ja-JP/title.txt
@@ -0,0 +1 @@
ja-JP title
1 change: 1 addition & 0 deletions supply/spec/fixtures/metadata/android/ja-JP/video.txt
@@ -0,0 +1 @@
ja-JP video
73 changes: 60 additions & 13 deletions supply/spec/uploader_spec.rb
Expand Up @@ -203,22 +203,69 @@ def find_obbs
end
end

describe '#perform_upload' do
let(:client) { double('client') }
let(:config) { { apk: 'some/path/app.apk' } }
RSpec::Matchers.define(:same_localizedtext_as) do |expected_arr|
match do |actual_arr|
actual_arr.sort_by(&:language).zip(expected_arr.sort_by(&:language)).map do |actual_localization, expected_localization|
actual_localization.language == expected_localization.language && actual_localization.text == expected_localization.text
end.reduce(:&)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't that be reduce(:&&) to join all tests by a logical AND operator, as opposed to use a bitwise-and & operator?
Though, in practice, if that's really the intent, you'd be better served by using all_satisfy? method instead.

Also, I wonder if using the built-in match_array RSpec matcher (which compares content of arrays without regard for the potential different order of the elements being compared) wouldn't be enough to compare those two arrays, instead of writing your custom matcher? (Though I'm not 100% if AndroidPublisher::LocalizedText has a proper implementation of == that compares each property for equality instead of using ObjectIDs…)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used reduce(:&) because & is the operator we have for TrueClass and FalseClass (there's no &&). I would prefer using the expression in that form rather than expand it to reduce { |a, b| a && b }. What do you think?


As for the matcher, I actually had to write a new one because we lack the support for == on AndroidPublisher::LocalizedText 😕 , so even match_array wouldn't help in that case. (it does a plain comparison of object ids as you implied last).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used reduce(:&) because & is the operator we have for TrueClass and FalseClass (there's no &&). I would prefer using the expression in that form rather than expand it to reduce { |a, b| a && b }. What do you think?

Then in that case I'd rather have you use all_satisfy?, which is more idiomatic Ruby for that kind of thing

As for the matcher, I actually had to write a new one because we lack the support for == on AndroidPublisher::LocalizedText 😕 , so even match_array wouldn't help in that case. (it does a plain comparison of object ids as you implied last).

What about declaring that == ourselves then? Since Ruby allows to re-open classes to add definitions 🙃

class AndroidPublisher::LocalizedText
  def ==(other)
    self.language == other.language && self.text == other.text
  end
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides, I think your implementation of that matcher has a hidden bug, because in a.zip(b), if a has less elements than b then the result of the zip will only have as many elements as a, ignoring the trailing elements of b:

$ pry
[1] pry(main)> a = [1,2]
=> [1, 2]
[2] pry(main)> b = [3,4,5]
=> [3, 4, 5]
[3] pry(main)> a.zip(b)
=> [[1, 3], [2, 4]]

Which means that if actual_arr has less elements than expected_arr, your matcher will pass instead of detecting a mismatch. Which is a bug that I don't thing match_array is subject to 🙃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .zip "bug" is actually a good point. The code should be changed.


I forgot about that we can open classes anywhere we want, that was a good call 😄 . I added the == override as you suggested and that simplified the test code a lot. Now we can get away with just a match_all call. That should solve the zip problem too.

end
end

before do
Supply.config = config
allow(Supply::Client).to receive(:make_from_config).and_return(client)
allow(client).to receive(:upload_apk).with(config[:apk]).and_return(1) # newly uploaded version code
allow(client).to receive(:begin_edit).and_return(nil)
allow(client).to receive(:commit_current_edit!).and_return(nil)
shared_examples 'run supply to upload metadata' do
describe '#perform_upload' do
subject(:release) { OpenStruct.new({ version_codes: version_codes }) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth using a double('release', version_codes: version_codes) instead here? (If I remember the syntax for creating rspec doubles correctly)

let(:client) { double('client') }
let(:config) { { apk_paths: version_codes.map { |v_code| "some/path/app-v#{v_code}.apk" }, metadata_path: 'supply/spec/fixtures/metadata/android', track: 'track-name' } }

before do
Supply.config = config
allow(Supply::Client).to receive(:make_from_config).and_return(client)
version_codes.each do |version_code|
allow(client).to receive(:upload_apk).with("some/path/app-v#{version_code}.apk").and_return(version_code) # newly uploaded version code
end
allow(client).to receive(:upload_changelogs).and_return(nil)
allow(client).to receive(:tracks).with('track-name').and_return([OpenStruct.new({ releases: [ release ] })])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not tested, but I think you could use an object double like double('tracks', releases: [release]) instead of using OpenStruct.new? Which would me more idiomatic of rspec tests practices I think?

languages.each do |lang|
allow(client).to receive(:listing_for_language).with(lang).and_return(Supply::Listing.new(client, lang))
allow(client).to receive(:update_listing_for_language).with({ language: lang, full_description: "#{lang} full description", short_description: "#{lang} short description", title: "#{lang} title", video: "#{lang} video" })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this one be an expect instead of an allow? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call. will change.

end
allow(client).to receive(:begin_edit).and_return(nil)
allow(client).to receive(:commit_current_edit!).and_return(nil)
end

it 'should update track with correct version codes and optional changelog' do
uploader = Supply::Uploader.new
expect(uploader).to receive(:update_track).with(version_codes).once
version_codes.each do |version_code|
expected_notes = []
languages.each do |lang|
expected_notes << AndroidPublisher::LocalizedText.new(
language: lang,
text: "#{lang} changelog #{with_explicit_changelogs ? version_code : -1}"
)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expected_notes = []
languages.each do |lang|
expected_notes << AndroidPublisher::LocalizedText.new(
language: lang,
text: "#{lang} changelog #{with_explicit_changelogs ? version_code : -1}"
)
end
expected_notes = languages.map do |lang|
AndroidPublisher::LocalizedText.new(
language: lang,
text: "#{lang} changelog #{with_explicit_changelogs ? version_code : -1}"
)
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha, my procedural instinct blinded me from idiomatic ruby 😄

# check if at least one of the assignments of release_notes is what we expect
expect(release).to receive(:release_notes=).with(same_localizedtext_as(expected_notes))
end

uploader.perform_upload
end
end
end

describe '#peform_upload with metadata and explicit changelogs' do
it_behaves_like 'run supply to upload metadata' do
let(:version_codes) { [1, 2] }
let(:languages) { ['en-US', 'fr-FR', 'ja-JP'] }
let(:with_explicit_changelogs) { true }
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it's just me being used to that pattern or an actual ruby/rspec codestyle recommendation, but I'm more used to see this kind of parametric tests be implemented as parameters passed to shared_examples, rather than shared example called with a do…end block redefining let constants for each case.

See https://railsware.com/blog/using-configurable-shared-examples-in-rspec/ for examples of such pattern with parametrized shared_examples.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for pointing that out. I'll update the test.

end

it 'should update track with correct version codes' do
uploader = Supply::Uploader.new
expect(uploader).to receive(:update_track).with([1]).once
uploader.perform_upload
describe '#peform_upload with metadata and default changelogs' do
it_behaves_like 'run supply to upload metadata' do
let(:version_codes) { [3] }
let(:languages) { ['en-US', 'fr-FR', 'ja-JP'] }
let(:with_explicit_changelogs) { false }
end
end

Expand Down