Skip to content

Commit 2556535

Browse files
committed
feat(docs): Compress the packages with zstd
Package the documentations as ".tar.zst" instead of ".tar.gz", which cuts their size by about a third at the same time as it speeds up extraction: doc tar gzip zstd -19 javascript 28.8 MB 3.38 MB 1.87 MB (-45%) python~3.14 42.9 MB 7.92 MB 5.34 MB (-33%) dom 131.5 MB 21.13 MB 14.42 MB (-32%) Keep downloading the ".tar.gz" of the documentations that have not been re-packaged since, as the bundle zone holds both formats from now on.
1 parent bdaaab3 commit 2556535

4 files changed

Lines changed: 86 additions & 19 deletions

File tree

‎Gemfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ group :docs do
4343
gem 'redcarpet'
4444
gem 'tty-pager', require: false
4545
gem 'unix_utils', require: false
46+
gem 'zstd-ruby', require: false
4647
end
4748

4849
group :test do

‎Gemfile.lock‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ GEM
312312
webrick (1.9.2)
313313
yajl-ruby (1.4.3)
314314
zeitwerk (2.7.5)
315+
zstd-ruby (2.0.9)
315316

316317
PLATFORMS
317318
aarch64-linux
@@ -363,6 +364,7 @@ DEPENDENCIES
363364
unix_utils
364365
webrick (~> 1.9)
365366
yajl-ruby
367+
zstd-ruby
366368

367369
CHECKSUMS
368370
actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3
@@ -506,6 +508,7 @@ CHECKSUMS
506508
webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
507509
yajl-ruby (1.4.3) sha256=8c974d9c11ae07b0a3b6d26efea8407269b02e4138118fbe3ef0d2ec9724d1d2
508510
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
511+
zstd-ruby (2.0.9) sha256=d6e7ffb61f3b93947c3a38afe2669e11acc8e8bbf15f55dc14dd03604c0d1083
509512

510513
RUBY VERSION
511514
ruby 4.0.6

‎docs/maintainers.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ In addition to the [publicly-documented commands](https://github.com/freeCodeCam
5252

5353
Generates packages for one or more documentations. Those packages are intended to be uploaded to DevDocs's S3 bundle zone by maintainers via the `thor docs:upload` command, and downloaded by users via the `thor docs:download` command.
5454

55+
Packages are `.tar.zst` files. Documentations packaged before the switch to zstd are still published as `.tar.gz`, which `thor docs:download` falls back to; they turn into `.tar.zst` as they are re-packaged and re-uploaded. Their `.tar.gz` must be kept on the bundle zone for as long as DevDocs installations predating the switch are to be supported.
56+
5557
Versions can be specified as such: `thor docs:package rails@5.2 node@10\ LTS`.
5658

5759
Packages can also be automatically generated during the scraping process by passing the `--package` option to `thor docs:generate`.

‎lib/tasks/docs.thor‎

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
class DocsCLI < Thor
22
include Thor::Actions
33

4+
# Packages are compressed with zstd, those created before the switch to it
5+
# are still gzipped.
6+
PACKAGE_EXTENSIONS = %w(tar.zst tar.gz).freeze
7+
8+
# Packaging happens once whereas the packages are downloaded over and over,
9+
# which makes the slowest compression level worth its time.
10+
ZSTD_COMPRESSION_LEVEL = 19
11+
CHUNK_SIZE = 1024 * 1024
12+
413
def self.to_s
514
'Docs'
615
end
@@ -15,8 +24,7 @@ class DocsCLI < Thor
1524
option :packaged, type: :boolean
1625
def list
1726
if options[:packaged]
18-
slugs = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
19-
names = find_docs_by_slugs(slugs).map do |doc|
27+
names = find_docs_by_slugs(packaged_slugs).map do |doc|
2028
name = if doc.version?
2129
"#{doc.superclass.to_s.demodulize.underscore}@#{doc.version}"
2230
else
@@ -161,7 +169,7 @@ class DocsCLI < Thor
161169

162170
desc 'clean', 'Delete documentation packages and cached responses'
163171
def clean
164-
File.delete(*Dir[File.join Docs.store_path, '*.tar.gz'])
172+
File.delete(*packaged_paths)
165173
Docs::ResponseCache.clean
166174
puts 'Done'
167175
end
@@ -172,8 +180,7 @@ class DocsCLI < Thor
172180
option :rclone, type: :boolean
173181
def upload(*names)
174182
if options[:packaged]
175-
slugs = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
176-
docs = find_docs_by_slugs(slugs)
183+
docs = find_docs_by_slugs(packaged_slugs)
177184
else
178185
docs = find_docs(names)
179186
end
@@ -187,7 +194,7 @@ class DocsCLI < Thor
187194
return
188195
end
189196

190-
unless File.exist?(File.join(Docs.store_path, "#{doc.path}.tar.gz"))
197+
if package_path(doc).nil?
191198
puts "ERROR: package for '#{doc.slug}' documentation not found. Run 'thor docs:package #{doc.slug}' to create it."
192199
return
193200
end
@@ -216,7 +223,7 @@ class DocsCLI < Thor
216223
puts '[S3 bundle] Begin uploading.'
217224

218225
docs.each do |doc|
219-
filename = "#{doc.path}.tar.gz"
226+
filename = File.basename(package_path(doc))
220227
puts "[S3 bundle] Uploading #{filename}..."
221228
cmd = "aws s3 cp #{File.join(Docs.store_path, filename)} s3://devdocs-downloads/#{filename} --profile devdocs"
222229
cmd << ' --dryrun' if options[:dryrun]
@@ -382,23 +389,35 @@ class DocsCLI < Thor
382389
if options[:rclone]
383390
require 'tmpdir'
384391
Dir.mktmpdir do |dir|
385-
system("rclone copy devdocs:devdocs-downloads/#{doc.path}.tar.gz #{dir}")
386-
tar_gz_path = File.join(dir, "#{File.basename(doc.path)}.tar.gz")
387-
raise "rclone did not download #{doc.path}.tar.gz (not found on remote?)" unless File.exist?(tar_gz_path)
388-
extract_doc(tar_gz_path, target_path)
392+
extension = PACKAGE_EXTENSIONS.find do |ext|
393+
system("rclone copy devdocs:devdocs-downloads/#{doc.path}.#{ext} #{dir}")
394+
File.exist?(File.join(dir, "#{File.basename(doc.path)}.#{ext}"))
395+
end
396+
raise "rclone did not download #{doc.path} (not found on remote?)" if extension.nil?
397+
extract_doc(File.join(dir, "#{File.basename(doc.path)}.#{extension}"), target_path, extension)
389398
end
390399
else
391-
URI.open "https://downloads.devdocs.io/#{doc.path}.tar.gz" do |file|
392-
file.close
393-
extract_doc(file.path, target_path)
394-
FileUtils.rm(file.path)
400+
extension = PACKAGE_EXTENSIONS.find do |ext|
401+
begin
402+
URI.open "https://downloads.devdocs.io/#{doc.path}.#{ext}" do |file|
403+
file.close
404+
extract_doc(file.path, target_path, ext)
405+
FileUtils.rm(file.path)
406+
end
407+
true
408+
rescue OpenURI::HTTPError => error
409+
# Packages missing from the bucket are reported as "403 Forbidden".
410+
raise unless %w(403 404).include?(error.io.status.first)
411+
false
412+
end
395413
end
414+
raise "#{doc.path} not found on downloads.devdocs.io" if extension.nil?
396415
end
397416
end
398417

399-
def extract_doc(tar_gz_path, target_path)
418+
def extract_doc(archive_path, target_path, extension)
400419
FileUtils.mkpath(target_path)
401-
tar = UnixUtils.gunzip(tar_gz_path)
420+
tar = extension == 'tar.zst' ? decompress_zstd(archive_path) : UnixUtils.gunzip(archive_path)
402421
dir = UnixUtils.untar(tar)
403422
FileUtils.rm(tar)
404423
FileUtils.rm_rf(target_path)
@@ -410,14 +429,56 @@ class DocsCLI < Thor
410429

411430
if File.exist?(path)
412431
tar = UnixUtils.tar(path)
413-
gzip = UnixUtils.gzip(tar)
414-
FileUtils.mv(gzip, "#{path}.tar.gz")
432+
FileUtils.mv(compress_zstd(tar), "#{path}.tar.zst")
415433
FileUtils.rm(tar)
416434
else
417435
puts %(ERROR: can't find "#{doc.name}" documentation files.)
418436
end
419437
end
420438

439+
def packaged_paths
440+
Dir[File.join(Docs.store_path, "*.{#{PACKAGE_EXTENSIONS.join(',')}}")]
441+
end
442+
443+
def packaged_slugs
444+
packaged_paths.map { |path| File.basename(path).sub(/\.#{Regexp.union(PACKAGE_EXTENSIONS)}\z/, '') }.uniq
445+
end
446+
447+
def package_path(doc)
448+
PACKAGE_EXTENSIONS.lazy.map { |extension| File.join(Docs.store_path, "#{doc.path}.#{extension}") }.find { |path| File.exist?(path) }
449+
end
450+
451+
def compress_zstd(path)
452+
require 'zstd-ruby'
453+
stream = Zstd::StreamingCompress.new(level: ZSTD_COMPRESSION_LEVEL)
454+
455+
write_tmp_file(path) do |input, output|
456+
output.write(stream.compress(input.read(CHUNK_SIZE))) until input.eof?
457+
output.write(stream.finish)
458+
end
459+
end
460+
461+
def decompress_zstd(path)
462+
require 'zstd-ruby'
463+
stream = Zstd::StreamingDecompress.new
464+
465+
write_tmp_file(path) do |input, output|
466+
output.write(stream.decompress(input.read(CHUNK_SIZE))) until input.eof?
467+
end
468+
end
469+
470+
# The packages are read and written chunk by chunk rather than at once,
471+
# as they are hundreds of megabytes big.
472+
def write_tmp_file(path)
473+
target = UnixUtils.tmp_path(path)
474+
475+
File.open(target, 'wb') do |output|
476+
File.open(path, 'rb') { |input| yield(input, output) }
477+
end
478+
479+
target
480+
end
481+
421482
def generate_manifest
422483
Docs.generate_manifest
423484
end

0 commit comments

Comments
 (0)